Files
server-manager/gui/widgets/terminal_widget.py
chrome-storm-c442 a83a97c9d5 v1.5.0: network interface binding, SSH fixes, terminal, release script
- Add network interface selection per server (VPN/multi-NIC support)
- Fix "Install Everything" button hanging on error
- Add interactive SSH terminal with PTY (pyte + xterm-256color)
- Add release.py for automated versioning and changelog generation
- Add CLAUDE.md with project instructions
- Add screenshots and release binaries for v1.1–v1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 14:06:41 -05:00

401 lines
13 KiB
Python

"""
Terminal widget — pyte VT100 emulator + tkinter.Text with ANSI colors,
full keyboard mapping, diff-based rendering, and cursor display.
"""
import tkinter as tk
import tkinter.font as tkfont
import pyte
# 16 standard ANSI colors
_ANSI_COLORS = {
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"brown": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightbrown": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
}
_DEFAULT_FG = "#d3d7cf"
_DEFAULT_BG = "#1a1a2e"
_CURSOR_FG = "#1a1a2e"
_CURSOR_BG = "#d3d7cf"
# Key → VT100 escape sequence
_KEY_MAP = {
"Up": "\x1b[A",
"Down": "\x1b[B",
"Right": "\x1b[C",
"Left": "\x1b[D",
"Home": "\x1b[H",
"End": "\x1b[F",
"Insert": "\x1b[2~",
"Delete": "\x1b[3~",
"Prior": "\x1b[5~", # PageUp
"Next": "\x1b[6~", # PageDown
"F1": "\x1bOP",
"F2": "\x1bOQ",
"F3": "\x1bOR",
"F4": "\x1bOS",
"F5": "\x1b[15~",
"F6": "\x1b[17~",
"F7": "\x1b[18~",
"F8": "\x1b[19~",
"F9": "\x1b[20~",
"F10": "\x1b[21~",
"F11": "\x1b[23~",
"F12": "\x1b[24~",
}
class TerminalWidget(tk.Frame):
"""VT100 terminal emulator widget using pyte + tkinter.Text."""
def __init__(self, master, cols=80, rows=24, send_callback=None,
resize_callback=None, **kwargs):
super().__init__(master, bg=_DEFAULT_BG, **kwargs)
self.send_callback = send_callback
self.resize_callback = resize_callback
self._cols = cols
self._rows = rows
# pyte screen + stream
self._screen = pyte.Screen(cols, rows)
self._screen.set_mode(pyte.modes.LNM)
self._stream = pyte.Stream(self._screen)
# Previous buffer for diff rendering
self._prev_buffer: dict[int, list] = {}
# Font
self._font = tkfont.Font(family="Consolas", size=11)
self._bold_font = tkfont.Font(family="Consolas", size=11, weight="bold")
# Text widget
self._text = tk.Text(
self,
bg=_DEFAULT_BG,
fg=_DEFAULT_FG,
font=self._font,
insertwidth=0,
highlightthickness=0,
borderwidth=0,
padx=4,
pady=4,
wrap="none",
cursor="xterm",
)
self._text.pack(fill="both", expand=True)
# Create color tags
self._setup_tags()
# Keyboard bindings
self._text.bind("<Key>", self._on_key)
self._text.bind("<Control-c>", self._on_ctrl_c)
self._text.bind("<Control-v>", self._on_ctrl_v)
self._text.bind("<Control-a>", lambda e: "break")
self._text.bind("<Control-d>", self._on_ctrl_d)
self._text.bind("<Control-l>", self._on_ctrl_l)
self._text.bind("<Control-z>", self._on_ctrl_z)
self._text.bind("<MouseWheel>", self._on_mousewheel)
# Resize handling
self._resize_after_id = None
self._text.bind("<Configure>", self._on_configure)
# Focus on click
self._text.bind("<Button-1>", lambda e: self._text.focus_set())
# Make text read-only (input goes through _on_key)
self._text.bind("<<Paste>>", lambda e: "break")
# Status bar
self._status_frame = tk.Frame(self, bg="#2d2d44", height=22)
self._status_frame.pack(fill="x", side="bottom")
self._status_frame.pack_propagate(False)
self._status_label = tk.Label(
self._status_frame, text="", fg="#888888", bg="#2d2d44",
font=("Consolas", 9), anchor="w", padx=6,
)
self._status_label.pack(fill="x")
def _setup_tags(self):
"""Pre-create tags for all ANSI color combinations."""
for name, color in _ANSI_COLORS.items():
self._text.tag_configure(f"fg_{name}", foreground=color)
self._text.tag_configure(f"bg_{name}", background=color)
self._text.tag_configure("bold", font=self._bold_font)
self._text.tag_configure("italic", font=tkfont.Font(family="Consolas", size=11, slant="italic"))
self._text.tag_configure("underline", underline=True)
self._text.tag_configure("cursor", foreground=_CURSOR_FG, background=_CURSOR_BG)
self._text.tag_configure("default", foreground=_DEFAULT_FG)
def set_status(self, text: str, color: str = "#888888"):
self._status_label.configure(text=text, fg=color)
def feed(self, data: bytes):
"""Feed raw bytes from SSH into pyte and re-render."""
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
self._stream.feed(text)
self._render()
def reset(self):
"""Reset the terminal screen."""
self._screen.reset()
self._prev_buffer.clear()
self._render()
def focus_terminal(self):
self._text.focus_set()
def get_size(self) -> tuple[int, int]:
"""Return (cols, rows) based on current widget size."""
return self._cols, self._rows
def _render(self):
"""Diff-based rendering: only update changed lines."""
self._text.configure(state="normal")
screen = self._screen
cursor = screen.cursor
for row in range(screen.lines):
line = screen.buffer[row]
# Build list of (char, attrs) for the row
current = []
for col in range(screen.columns):
char_data = line[col]
current.append((char_data.data, char_data.fg, char_data.bg,
char_data.bold, char_data.italics, char_data.underscore))
prev = self._prev_buffer.get(row)
is_cursor_row = (row == cursor.y)
prev_was_cursor_row = hasattr(self, "_prev_cursor_y") and self._prev_cursor_y == row
if current == prev and not is_cursor_row and not prev_was_cursor_row:
continue
self._prev_buffer[row] = current
# Delete the line
line_start = f"{row + 1}.0"
line_end = f"{row + 1}.end"
self._text.delete(line_start, line_end)
# Ensure enough lines exist
while int(self._text.index("end-1c").split(".")[0]) <= row:
self._text.insert("end", "\n")
# Build line content with batched tags
col = 0
while col < len(current):
ch, fg, bg, bold, italic, underline = current[col]
# Batch consecutive chars with same attributes
batch = [ch]
while col + len(batch) < len(current):
nc = current[col + len(batch)]
if nc[1:] == (fg, bg, bold, italic, underline):
batch.append(nc[0])
else:
break
text = "".join(batch)
tags = self._make_tags(fg, bg, bold, italic, underline)
# Apply cursor tag
if is_cursor_row:
if col <= cursor.x < col + len(batch):
# Split at cursor position
pre_len = cursor.x - col
if pre_len > 0:
self._text.insert(f"{row + 1}.{col}", text[:pre_len], tags)
cursor_tags = ("cursor",) + tags
self._text.insert(f"{row + 1}.{cursor.x}", text[pre_len:pre_len + 1], cursor_tags)
post = text[pre_len + 1:]
if post:
self._text.insert(f"{row + 1}.{cursor.x + 1}", post, tags)
col += len(batch)
continue
self._text.insert(f"{row + 1}.{col}", text, tags)
col += len(batch)
# Trim excess lines
total_lines = int(self._text.index("end-1c").split(".")[0])
if total_lines > screen.lines:
self._text.delete(f"{screen.lines + 1}.0", "end")
self._prev_cursor_y = cursor.y
self._text.configure(state="disabled")
def _make_tags(self, fg, bg, bold, italic, underline) -> tuple:
tags = []
fg_color = self._resolve_color(fg, is_fg=True)
if fg_color:
tag = f"fg_{fg_color}" if fg_color in _ANSI_COLORS else None
if tag and tag.replace("fg_", "") in _ANSI_COLORS:
tags.append(tag)
bg_color = self._resolve_color(bg, is_fg=False)
if bg_color:
tag = f"bg_{bg_color}" if bg_color in _ANSI_COLORS else None
if tag and tag.replace("bg_", "") in _ANSI_COLORS:
tags.append(tag)
if bold:
tags.append("bold")
if italic:
tags.append("italic")
if underline:
tags.append("underline")
return tuple(tags)
def _resolve_color(self, color, is_fg=True):
if not color or color == "default":
return None
if color in _ANSI_COLORS:
return color
# pyte may return color names like "red", "green" etc directly
return None
def _on_key(self, event):
"""Handle keyboard input and send to SSH."""
# Ignore modifier-only keys
if event.keysym in ("Shift_L", "Shift_R", "Control_L", "Control_R",
"Alt_L", "Alt_R", "Meta_L", "Meta_R",
"Caps_Lock", "Num_Lock"):
return "break"
# Ctrl+key combinations
if event.state & 0x4: # Control
if event.keysym.lower() in ("c", "v", "d", "l", "z"):
return # handled by specific bindings
ch = event.keysym.lower()
if len(ch) == 1 and "a" <= ch <= "z":
self._send(bytes([ord(ch) - ord("a") + 1]))
return "break"
# Special keys
if event.keysym in _KEY_MAP:
self._send(_KEY_MAP[event.keysym].encode())
return "break"
# Tab
if event.keysym == "Tab":
self._send(b"\t")
return "break"
# Return / Enter
if event.keysym in ("Return", "KP_Enter"):
self._send(b"\r")
return "break"
# Backspace
if event.keysym == "BackSpace":
self._send(b"\x7f")
return "break"
# Escape
if event.keysym == "Escape":
self._send(b"\x1b")
return "break"
# Regular character
if event.char and ord(event.char) >= 32:
self._send(event.char.encode("utf-8"))
return "break"
return "break"
def _on_ctrl_c(self, event):
# Check if text is selected — if so, copy
try:
sel = self._text.get("sel.first", "sel.last")
if sel:
self.clipboard_clear()
self.clipboard_append(sel)
return "break"
except tk.TclError:
pass
# No selection — send SIGINT
self._send(b"\x03")
return "break"
def _on_ctrl_v(self, event):
try:
text = self.clipboard_get()
if text:
self._send(text.encode("utf-8"))
except tk.TclError:
pass
return "break"
def _on_ctrl_d(self, event):
self._send(b"\x04")
return "break"
def _on_ctrl_l(self, event):
self._send(b"\x0c")
return "break"
def _on_ctrl_z(self, event):
self._send(b"\x1a")
return "break"
def _on_mousewheel(self, event):
# Send scroll sequences for programs like less/man
if event.delta > 0:
self._send(b"\x1b[5~") # PageUp
else:
self._send(b"\x1b[6~") # PageDown
return "break"
def _send(self, data: bytes):
if self.send_callback:
self.send_callback(data)
def _on_configure(self, event):
"""Debounced resize handler."""
if self._resize_after_id:
self.after_cancel(self._resize_after_id)
self._resize_after_id = self.after(200, self._do_resize)
def _do_resize(self):
self._resize_after_id = None
w = self._text.winfo_width()
h = self._text.winfo_height()
if w < 20 or h < 20:
return
char_w = self._font.measure("M")
char_h = self._font.metrics("linespace")
if char_w <= 0 or char_h <= 0:
return
new_cols = max(20, (w - 8) // char_w)
new_rows = max(4, (h - 8) // char_h)
if new_cols != self._cols or new_rows != self._rows:
self._cols = new_cols
self._rows = new_rows
self._screen.resize(new_rows, new_cols)
self._prev_buffer.clear()
self._render()
if self.resize_callback:
self.resize_callback(new_cols, new_rows)