Files
clide/legacy/clide/widgets/components/terminal_display.py
T
jpmschweitzerandClaude Opus 4.7 a355751437 move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:30:51 +02:00

652 lines
22 KiB
Python

"""Reusable PTY-based terminal emulator widget using pyte."""
from __future__ import annotations
import asyncio
import codecs
import fcntl
import os
import pty
import re
import struct
import termios
import time
from rich.text import Text
from textual.strip import Strip
from textual.widget import Widget
from clide.services.settings_service import get_settings_service
# Use vendored pyte with diagnostic logging support
from clide.vendor import pyte
def _setup_terminal_debug_logging() -> None:
"""Set up terminal debug logging if enabled in settings."""
settings = get_settings_service()
if not settings.get("terminal_debug", False):
return
# Create log file in settings directory
log_path = settings.settings_dir / "terminal_debug.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
# Open log file (append mode)
log_file = open(log_path, "a", encoding="utf-8")
def debug_logger(message: str) -> None:
"""Log debug message with timestamp."""
import datetime
timestamp = datetime.datetime.now().isoformat()
log_file.write(f"[{timestamp}] {message}\n")
log_file.flush()
# Set up pyte debug logging
pyte.set_debug_logger(debug_logger)
class TerminalDisplay(Widget, can_focus=True):
"""A terminal emulator widget using pyte."""
DEFAULT_CSS = """
TerminalDisplay {
height: 100%;
width: 100%;
background: $background;
}
"""
# Internal padding (rendered as part of terminal content, uses terminal bg)
PADDING_LEFT = 1
PADDING_RIGHT = 1
def __init__(
self,
cols: int = 80,
rows: int = 24,
**kwargs,
) -> None:
super().__init__(**kwargs)
self._cols = cols
self._rows = rows
# Set up debug logging before creating pyte objects
_setup_terminal_debug_logging()
self._screen = pyte.HistoryScreen(cols, rows, history=1000)
self._screen.set_mode(pyte.modes.LNM) # Line feed mode
self._stream = pyte.Stream(self._screen)
self._master_fd: int | None = None
self._pid: int | None = None
self._read_task: asyncio.Task | None = None
self._refresh_task: asyncio.Task | None = None
self._needs_refresh: bool = False
self._last_refresh: float = 0
self._pending_start: tuple[str, str] | None = None
# Incremental UTF-8 decoder to handle partial sequences at buffer boundaries
self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# Scroll offset for viewing history (0 = at bottom/current, positive = scrolled up)
self._scroll_offset: int = 0
def on_mount(self) -> None:
"""Initialize terminal size from widget dimensions."""
# Get actual widget size, accounting for internal padding
size = self.size
if size.width > 0 and size.height > 0:
self._cols = max(size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
self._rows = size.height
self._screen.resize(self._rows, self._cols)
# If start was called before mount, do it now
if self._pending_start:
command, cwd = self._pending_start
self._pending_start = None
self._do_start(command, cwd)
def on_resize(self, event) -> None:
"""Handle terminal resize."""
# Get new size from widget, accounting for internal padding
new_cols = max(self.size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
new_rows = max(self.size.height, 5)
if new_cols != self._cols or new_rows != self._rows:
old_cols = self._cols
# Update dimensions first
self._cols = new_cols
self._rows = new_rows
# Resize pyte screen - this will preserve content where possible
self._screen.resize(new_rows, new_cols)
# If screen got wider, clear the new columns to avoid stale data
# pyte's resize should handle this, but let's be defensive
if new_cols > old_cols:
for y in range(new_rows):
line = self._screen.buffer[y]
for x in range(old_cols, new_cols):
# Clear any stale data in new columns
line[x] = pyte.screens.Char(" ")
# Update PTY size if running - child process will redraw
if self._master_fd is not None:
self._set_pty_size(self._master_fd, new_rows, new_cols)
# Force a full refresh
self.refresh()
def _set_pty_size(self, fd: int, rows: int, cols: int) -> None:
"""Set the PTY window size and notify the child process."""
try:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
# Send SIGWINCH to notify the child process of resize
if self._pid is not None:
import signal
try:
os.kill(self._pid, signal.SIGWINCH)
except OSError:
pass
except OSError:
pass
def start(self, command: str, cwd: str) -> None:
"""Start a process in the terminal."""
# If not mounted yet, defer start
if not self.is_mounted:
self._pending_start = (command, cwd)
return
self._do_start(command, cwd)
def _do_start(self, command: str, cwd: str) -> None:
"""Actually start the process in the terminal."""
# Reset decoder state for new process
self._decoder.reset()
# Get current widget size, accounting for internal padding
size = self.size
if size.width > 0 and size.height > 0:
self._cols = max(size.width - self.PADDING_LEFT - self.PADDING_RIGHT, 20)
self._rows = size.height
self._screen.resize(self._rows, self._cols)
# Fork a PTY
pid, master_fd = pty.fork()
if pid == 0:
# Child process
os.chdir(cwd)
# Strip Zellij env vars so child processes don't think
# they're inside a Zellij session (Clide may run inside
# Zellij for web deployment session persistence)
for key in list(os.environ):
if key.startswith("ZELLIJ"):
del os.environ[key]
os.environ["TERM"] = "xterm-256color"
os.environ["COLORTERM"] = "truecolor"
os.environ["COLUMNS"] = str(self._cols)
os.environ["LINES"] = str(self._rows)
# Launch through a login shell so profile scripts are sourced
# and PATH includes locations like ~/.local/bin where Claude's
# native binary may be installed.
# Use /etc/passwd shell, not $SHELL (which Zellij may override)
import pwd
try:
shell = pwd.getpwuid(os.getuid()).pw_shell
except KeyError:
shell = "/bin/bash"
os.execvp(shell, [shell, "-l", "-c", command])
else:
# Parent process
self._pid = pid
self._master_fd = master_fd
# Set non-blocking
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
# Set initial size
self._set_pty_size(master_fd, self._rows, self._cols)
# Start reading
self._read_task = asyncio.create_task(self._read_output())
def _schedule_refresh(self) -> None:
"""Schedule a throttled refresh to avoid glitching."""
self._needs_refresh = True
if self._refresh_task is None or self._refresh_task.done():
self._refresh_task = asyncio.create_task(self._throttled_refresh())
async def _throttled_refresh(self) -> None:
"""Refresh at most every 16ms (~60fps) to avoid glitching."""
min_interval = 0.016 # ~60fps
while self._needs_refresh:
now = time.monotonic()
elapsed = now - self._last_refresh
if elapsed < min_interval:
await asyncio.sleep(min_interval - elapsed)
self._needs_refresh = False
self._last_refresh = time.monotonic()
self.refresh()
# Small delay to batch rapid updates
await asyncio.sleep(0.008)
# Regex to filter escape sequences that pyte doesn't handle
# Kitty keyboard protocol, bracketed paste mode queries, etc.
_UNSUPPORTED_ESCAPES = re.compile(
r"\x1b\[[\=\>\<][0-9;]*[a-zA-Z]" # Kitty keyboard protocol (=, >, or < prefix)
r"|\x1b\[\?[0-9;]*u" # Kitty keyboard query
r"|\x1b\[\?[0-9;]*c" # Device attributes query
r"|\x1b\[>[0-9;]*c" # Secondary device attributes
r"|\x1b\]\d+;[^\x07\x1b]*(?:\x07|\x1b\\)" # OSC sequences (title, etc.)
r"|\x1b\[\?2026[hl]" # Synchronized update mode (not used by pyte)
)
def _filter_unsupported_escapes(self, data: str) -> str:
"""Filter out escape sequences that pyte doesn't handle."""
return self._UNSUPPORTED_ESCAPES.sub("", data)
async def _read_output(self) -> None:
"""Read output from the PTY."""
if self._master_fd is None:
return
while True:
try:
# Wait for data to be available
await asyncio.sleep(0.005)
try:
data = os.read(self._master_fd, 65536)
if not data:
break
# Use incremental decoder to handle partial UTF-8 sequences
# at buffer boundaries (prevents box-drawing chars getting corrupted)
text = self._decoder.decode(data)
if not text:
continue # Still waiting for more bytes to complete a sequence
text = self._filter_unsupported_escapes(text)
# Feed data to pyte
self._stream.feed(text)
self._schedule_refresh()
except BlockingIOError:
# No data available
continue
except OSError:
# PTY closed
break
except asyncio.CancelledError:
break
def stop(self) -> None:
"""Stop the terminal process."""
if self._read_task:
self._read_task.cancel()
self._read_task = None
if self._refresh_task:
self._refresh_task.cancel()
self._refresh_task = None
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
self._master_fd = None
if self._pid is not None:
try:
os.kill(self._pid, 9)
os.waitpid(self._pid, 0)
except (OSError, ChildProcessError):
pass
self._pid = None
def is_running(self) -> bool:
"""Check if the process is still running."""
if self._pid is None:
return False
try:
pid, status = os.waitpid(self._pid, os.WNOHANG)
if pid == 0:
return True # Still running
else:
self._pid = None
return False
except ChildProcessError:
self._pid = None
return False
def send(self, data: str) -> None:
"""Send data to the terminal."""
if self._master_fd is not None:
try:
os.write(self._master_fd, data.encode("utf-8"))
except OSError:
pass
# ANSI 256-color palette (standard 16 colors, softened for dark themes)
ANSI_COLORS = {
"black": "#282c34",
"red": "#e06c75",
"green": "#98c379",
"brown": "#e5c07b",
"yellow": "#e5c07b",
"blue": "#61afef",
"magenta": "#c678dd",
"cyan": "#56b6c2",
"white": "#abb2bf",
"brightblack": "#5c6370",
"brightred": "#e06c75",
"brightgreen": "#98c379",
"brightbrown": "#e5c07b",
"brightyellow": "#e5c07b",
"brightblue": "#61afef",
"brightmagenta": "#c678dd",
"brightcyan": "#56b6c2",
"brightwhite": "#ffffff",
}
def _convert_color(self, color: str, is_bg: bool = False) -> str | None:
"""Convert pyte color to Rich color string."""
if not color or color == "default":
return None
# Handle hex colors (with or without # prefix)
if color.startswith("#"):
return color
# Check if it's a hex color without # (pyte returns "ff0000" not "#ff0000")
if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
return f"#{color}"
# Handle named ANSI colors
color_lower = color.lower()
if color_lower in self.ANSI_COLORS:
return self.ANSI_COLORS[color_lower]
# Handle 256-color palette (numeric)
try:
num = int(color)
if 0 <= num <= 255:
return f"color({num})"
except ValueError:
pass
# Fallback: try to use the color name directly
return color
def _get_line_at(self, y: int):
"""Get the line at display position y, accounting for scroll offset.
When scrolled, we show lines from history mixed with current buffer.
scroll_offset=0 means showing current screen.
scroll_offset=N means the top of display shows N lines back in history.
"""
history = self._screen.history.top
history_len = len(history)
if self._scroll_offset == 0:
# Not scrolled - show current buffer
return self._screen.buffer[y]
# Calculate which line to show
# Display line 0 should show history[history_len - scroll_offset]
# Display line N should show history[history_len - scroll_offset + N]
# If that index >= history_len, we're into the current buffer
history_index = history_len - self._scroll_offset + y
if history_index < 0:
# Before start of history - return blank
return None
elif history_index < history_len:
# In history
return history[history_index]
else:
# In current buffer
buffer_index = history_index - history_len
if buffer_index < self._rows:
return self._screen.buffer[buffer_index]
return None
def render_line(self, y: int) -> Strip:
"""Render a line of the terminal."""
# Screen buffer dimensions (what pyte has)
screen_cols = self._cols
screen_rows = self._rows
# Widget display dimensions (what we need to output)
output_width = (
self.size.width
if self.size.width > 0
else screen_cols + self.PADDING_LEFT + self.PADDING_RIGHT
)
if y >= screen_rows:
return Strip.blank(output_width)
# Get the line to render, accounting for scroll offset
line = self._get_line_at(y)
if line is None:
return Strip.blank(output_width)
text = Text()
# Add left padding (uses terminal background)
text.append(" " * self.PADDING_LEFT)
# Only access indices within the screen buffer
cols_to_render = min(screen_cols, output_width - self.PADDING_LEFT - self.PADDING_RIGHT)
# Debug logging for render pipeline
debug_logger = pyte.get_debug_logger()
log_this_line = False
for x in range(cols_to_render):
char = line[x]
char_data = char.data if char.data else " "
# Check for box-drawing and other potentially problematic characters
if len(char_data) == 1:
code = ord(char_data)
# Log box-drawing characters (U+2500-U+257F)
if 0x2500 <= code <= 0x257F:
log_this_line = True
if debug_logger:
debug_logger(
f"RENDER y={y} x={x}: box-drawing U+{code:04X} char='{char_data}'"
)
# Handle characters that may not render correctly
if len(char_data) == 1:
code = ord(char_data)
# Control characters (except space)
if code < 32 and code != 0 or 127 <= code <= 159:
char_data = " "
# Braille patterns (U+2800-U+28FF) - used for spinners
# Replace with simple ASCII spinner chars or spaces
elif 0x2800 <= code <= 0x28FF:
# Map braille spinner to simple dots
char_data = "·"
# Build style from pyte character attributes
style_parts = []
# Foreground color
fg = self._convert_color(char.fg)
if fg:
style_parts.append(fg)
# Background color
bg = self._convert_color(char.bg, is_bg=True)
if bg:
style_parts.append(f"on {bg}")
# Text attributes
if char.bold:
style_parts.append("bold")
if char.italics:
style_parts.append("italic")
if char.underscore:
style_parts.append("underline")
if char.strikethrough:
style_parts.append("strike")
if char.reverse:
style_parts.append("reverse")
style = " ".join(style_parts) if style_parts else None
text.append(char_data, style=style)
# Pad with spaces to fill remaining width (includes right padding)
content_width = self.PADDING_LEFT + cols_to_render
if output_width > content_width:
text.append(" " * (output_width - content_width))
# Debug: log the Rich Text content before rendering
if log_this_line and debug_logger:
debug_logger(f"RENDER y={y}: Rich Text plain='{text.plain[:80]}...'")
# Render text to segments for Strip
segments = list(text.render(self.app.console))
# Debug: log segments if we had box-drawing chars
if log_this_line and debug_logger:
for i, seg in enumerate(segments[:10]): # First 10 segments
seg_text = seg.text if hasattr(seg, "text") else str(seg)
if len(seg_text) <= 5:
debug_logger(f"RENDER y={y} seg[{i}]: '{seg_text}' (repr: {repr(seg_text)})")
strip = Strip(segments)
# Ensure strip is exactly the output width
if strip.cell_length != output_width:
strip = strip.crop_extend(0, output_width, None)
return strip
def on_key(self, event) -> None:
"""Handle key presses."""
# Map special keys
key_map = {
"enter": "\r",
"tab": "\t",
"backspace": "\x7f",
"delete": "\x1b[3~",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"home": "\x1b[H",
"end": "\x1b[F",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"escape": "\x1b",
}
if event.key in key_map:
self.send(key_map[event.key])
event.prevent_default()
event.stop()
elif event.key == "ctrl+c":
self.send("\x03")
event.prevent_default()
event.stop()
elif event.key == "ctrl+d":
self.send("\x04")
event.prevent_default()
event.stop()
elif event.key == "ctrl+z":
self.send("\x1a")
event.prevent_default()
event.stop()
elif event.key == "ctrl+l":
self.send("\x0c")
event.prevent_default()
event.stop()
elif event.key == "shift+pageup":
# Scroll up in history
self._scroll_up(self._rows // 2)
event.prevent_default()
event.stop()
elif event.key == "shift+pagedown":
# Scroll down in history
self._scroll_down(self._rows // 2)
event.prevent_default()
event.stop()
elif event.key == "shift+home":
# Scroll to top of history
self._scroll_to_top()
event.prevent_default()
event.stop()
elif event.key == "shift+end":
# Scroll to bottom (current)
self._scroll_to_bottom()
event.prevent_default()
event.stop()
elif event.character and len(event.character) == 1:
# Any typing scrolls to bottom
self._scroll_to_bottom()
self.send(event.character)
event.prevent_default()
event.stop()
def on_mouse_scroll_up(self, event) -> None:
"""Handle mouse scroll up (view older content)."""
self._scroll_up(3)
event.prevent_default()
event.stop()
def on_mouse_scroll_down(self, event) -> None:
"""Handle mouse scroll down (view newer content)."""
self._scroll_down(3)
event.prevent_default()
event.stop()
def _scroll_up(self, lines: int) -> None:
"""Scroll up (back in history) by given number of lines."""
max_scroll = len(self._screen.history.top)
new_offset = min(self._scroll_offset + lines, max_scroll)
if new_offset != self._scroll_offset:
self._scroll_offset = new_offset
self.refresh()
def _scroll_down(self, lines: int) -> None:
"""Scroll down (forward toward current) by given number of lines."""
new_offset = max(self._scroll_offset - lines, 0)
if new_offset != self._scroll_offset:
self._scroll_offset = new_offset
self.refresh()
def _scroll_to_top(self) -> None:
"""Scroll to the top of history."""
max_scroll = len(self._screen.history.top)
if self._scroll_offset != max_scroll:
self._scroll_offset = max_scroll
self.refresh()
def _scroll_to_bottom(self) -> None:
"""Scroll to the bottom (current screen)."""
if self._scroll_offset != 0:
self._scroll_offset = 0
self.refresh()
@property
def history_size(self) -> int:
"""Get the number of lines in scrollback history."""
return len(self._screen.history.top)