Files
mcp-hal9002/src/gnome_vte_mcp/gui.py
T

1366 lines
53 KiB
Python

from __future__ import annotations
import argparse
import base64
import cairo
from datetime import datetime
import json
import os
import re
import shlex
import socketserver
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import gi
gi.require_version("Gdk", "4.0")
gi.require_version("Gsk", "4.0")
gi.require_version("Graphene", "1.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Vte", "3.91")
from gi.repository import Gdk, Gio, GLib, Graphene, Gsk, Gtk, Vte
from .control import default_socket_path
DEFAULT_SCROLLBACK_LINES = 10000
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)", re.DOTALL)
SINGLE_ESCAPE_RE = re.compile(r"\x1b[@-_]")
COMMAND_START_MARKER = "__MCP_HAL9002_CMD_START__"
COMMAND_END_MARKER = "__MCP_HAL9002_CMD_END__"
@dataclass
class TabState:
tab_id: str
title: str
container: Gtk.Overlay
scroller: Gtk.ScrolledWindow
terminal: Vte.Terminal
welcome_revealer: Gtk.Revealer
diagnostic_layer: Gtk.DrawingArea
log_path: Path
command_events_path: Path
history_path: Path
shell_rc_path: Path
cwd: str
last_command: str | None
last_command_token: str | None
pending_submit_id: str | None
pending_submit_text: str | None
pending_submit_requested_at: str | None
last_manual_submit_id: str | None
last_manual_submit_text: str | None
last_manual_submit_at: str | None
current_execution_submission_id: str | None
current_execution_command: str | None
current_execution_started_at: str | None
current_execution_after_sequence: int | None
delegated_session_submission_id: str | None
delegated_session_command: str | None
delegated_session_started_at: str | None
delegated_session_after_sequence: int | None
class TerminalWindow(Gtk.ApplicationWindow):
def __init__(self, app: "TerminalApp") -> None:
super().__init__(application=app)
self.set_title("mcp-hal9002")
self.set_default_size(1100, 720)
self.set_hide_on_close(False)
self._app = app
self._diagnostic_config: dict[str, Any] | None = None
self._stack = Gtk.Stack()
self._stack.set_vexpand(True)
self._stack.set_hexpand(True)
self._switcher = Gtk.StackSwitcher()
self._switcher.set_stack(self._stack)
self._switcher.set_tooltip_text("Switch terminal tabs")
self._title_label = Gtk.Label(label="MCP HAL9002")
self._title_label.add_css_class("heading")
self._subtitle_label = Gtk.Label(label="Remote terminal cockpit")
self._subtitle_label.add_css_class("caption")
self._title_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._title_box.set_halign(Gtk.Align.CENTER)
self._title_box.append(self._title_label)
self._title_box.append(self._subtitle_label)
header = Gtk.HeaderBar()
self._header = header
header.set_show_title_buttons(True)
header.set_title_widget(self._title_box)
add_button = Gtk.Button.new_from_icon_name("tab-new-symbolic")
add_button.add_css_class("flat")
add_button.set_tooltip_text("Open a new terminal tab")
add_button.connect("clicked", self._on_new_tab_clicked)
header.pack_end(add_button)
self.set_titlebar(header)
self._root_overlay = Gtk.Overlay()
self._root_overlay.set_child(self._stack)
self._diagnostic_layer = Gtk.DrawingArea()
self._diagnostic_layer.set_hexpand(True)
self._diagnostic_layer.set_vexpand(True)
self._diagnostic_layer.set_can_target(False)
self._diagnostic_layer.set_draw_func(self._draw_window_diagnostic_overlay)
self._root_overlay.add_overlay(self._diagnostic_layer)
self._root_overlay.set_measure_overlay(self._diagnostic_layer, True)
self._root_overlay.set_clip_overlay(self._diagnostic_layer, False)
self._diagnostic_layer.set_visible(False)
self.set_child(self._root_overlay)
self.sync_header_state(tab_count=0)
@property
def stack(self) -> Gtk.Stack:
return self._stack
def sync_header_state(self, tab_count: int) -> None:
if tab_count > 1:
self._header.set_title_widget(self._switcher)
else:
self._header.set_title_widget(self._title_box)
def set_diagnostic_overlay(self, config: dict[str, Any] | None) -> None:
self._diagnostic_config = config
self._diagnostic_layer.set_visible(config is not None)
self._diagnostic_layer.queue_draw()
def _draw_window_diagnostic_overlay(
self,
_area: Gtk.DrawingArea,
ctx: Any,
width: int,
height: int,
) -> None:
self._app.draw_diagnostic_overlay(
ctx,
width,
height,
self._diagnostic_config,
)
def _on_new_tab_clicked(self, _button: Gtk.Button) -> None:
self._app.create_tab(title="shell")
class ControlRequestHandler(socketserver.StreamRequestHandler):
def handle(self) -> None:
line = self.rfile.readline()
if not line:
return
try:
payload = json.loads(line.decode("utf-8"))
method = payload["method"]
params = payload.get("params", {})
result = self.server.app.call_on_ui_thread(method, params)
response = {"id": payload.get("id"), "ok": True, "result": result}
except Exception as exc: # noqa: BLE001
response = {"id": payload.get("id") if "payload" in locals() else None, "ok": False, "error": str(exc)}
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
self.wfile.flush()
class ControlServer(socketserver.ThreadingUnixStreamServer):
allow_reuse_address = True
def __init__(self, socket_path: str, app: "TerminalApp") -> None:
self.app = app
super().__init__(socket_path, ControlRequestHandler)
class TerminalApp(Gtk.Application):
def __init__(self, socket_path: Path) -> None:
super().__init__(application_id="net.enne2.McpHal9002")
self.socket_path = socket_path
self.log_dir = self.socket_path.parent / "mcp-hal9002-logs"
self.screenshot_dir = self.socket_path.parent / "mcp-hal9002-screenshots"
self.window: TerminalWindow | None = None
self.tabs: dict[str, TabState] = {}
self.server: ControlServer | None = None
self.server_thread: threading.Thread | None = None
self.connect("activate", self.on_activate)
def on_activate(self, _app: Gtk.Application) -> None:
if self.window is None:
self.window = TerminalWindow(self)
self.start_control_server()
self.create_tab(title="shell")
self.window.present()
def start_control_server(self) -> None:
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
self.log_dir.mkdir(parents=True, exist_ok=True)
self.screenshot_dir.mkdir(parents=True, exist_ok=True)
if self.socket_path.exists():
self.socket_path.unlink()
self.server = ControlServer(os.fspath(self.socket_path), self)
self.server_thread = threading.Thread(target=self.server.serve_forever, name="gui-control-server", daemon=True)
self.server_thread.start()
def do_shutdown(self) -> None:
if self.server is not None:
self.server.shutdown()
self.server.server_close()
if self.socket_path.exists():
self.socket_path.unlink()
Gtk.Application.do_shutdown(self)
def call_on_ui_thread(self, method: str, params: dict[str, Any]) -> Any:
outcome: dict[str, Any] = {}
event = threading.Event()
def invoke() -> bool:
try:
handler = getattr(self, f"rpc_{method}")
except AttributeError as exc:
outcome["error"] = RuntimeError(f"Unknown method: {method}")
else:
try:
outcome["result"] = handler(**params)
except Exception as exc: # noqa: BLE001
outcome["error"] = exc
event.set()
return False
GLib.idle_add(invoke)
event.wait()
if "error" in outcome:
raise outcome["error"]
return outcome.get("result")
def _format_tab_title(self, cwd: str | None = None, command: str | None = None, title: str | None = None) -> str:
normalized_title = (title or "").strip()
if normalized_title and normalized_title.lower() != "shell":
candidate = normalized_title
elif command:
try:
first_token = shlex.split(command)[0]
except ValueError:
first_token = command.strip().split()[0] if command.strip() else "shell"
candidate = os.path.basename(first_token) or first_token or "shell"
elif cwd:
expanded = os.path.expanduser(cwd)
home = os.path.expanduser("~")
if expanded == home:
candidate = "Home"
else:
candidate = os.path.basename(expanded.rstrip(os.sep)) or expanded
else:
candidate = "shell"
candidate = candidate.strip() or "shell"
if len(candidate) > 18:
return candidate[:15] + "..."
return candidate
def _update_tab_title(self, tab: TabState, title: str) -> None:
tab.title = title
if self.window is None:
return
page = self.window.stack.get_page(tab.container)
page.set_title(title)
def _dismiss_onboarding(self, tab: TabState) -> None:
if tab.welcome_revealer.is_visible() or tab.welcome_revealer.get_reveal_child():
tab.welcome_revealer.set_reveal_child(False)
tab.welcome_revealer.set_visible(False)
def _configure_tab_diagnostic_overlay(self, tab: TabState, config: dict[str, Any] | None) -> None:
tab.diagnostic_layer._diagnostic_config = config # type: ignore[attr-defined]
tab.diagnostic_layer.set_visible(config is not None)
tab.diagnostic_layer.queue_draw()
def _annotate_screenshot_with_diagnostics(
self,
image_path: Path,
*,
width: int,
height: int,
label: str,
bounds_in_window: dict[str, Any] | None,
) -> None:
surface = cairo.ImageSurface.create_from_png(os.fspath(image_path))
ctx = cairo.Context(surface)
self.draw_diagnostic_overlay(
ctx,
width,
height,
{
"grid_step": 48,
"bounds": {"x": 0.0, "y": 0.0, "width": width, "height": height},
"label": label,
},
)
if bounds_in_window is not None:
alloc_text = (
f"window alloc {int(bounds_in_window.get('x', 0))},{int(bounds_in_window.get('y', 0))} "
f"{int(bounds_in_window.get('width', width))}x{int(bounds_in_window.get('height', height))}"
)
ctx.set_source_rgba(0.08, 0.08, 0.08, 0.88)
ctx.rectangle(12.0, max(height - 34.0, 0.0), max(len(alloc_text) * 7.0, 170.0), 22.0)
ctx.fill()
ctx.set_source_rgba(1.0, 1.0, 1.0, 0.98)
ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
ctx.set_font_size(12.0)
ctx.move_to(18.0, max(height - 18.0, 12.0))
ctx.show_text(alloc_text)
surface.write_to_png(os.fspath(image_path))
def draw_diagnostic_overlay(
self,
ctx: Any,
width: int,
height: int,
config: dict[str, Any] | None,
) -> None:
if config is None:
return
try:
grid_step = int(config.get("grid_step", 48))
bounds = config.get("bounds") or {}
label = config.get("label", "")
ctx.set_source_rgba(1.0, 1.0, 1.0, 0.12)
ctx.set_line_width(1.0)
for x in range(grid_step, width, grid_step):
ctx.move_to(x + 0.5, 0)
ctx.line_to(x + 0.5, height)
for y in range(grid_step, height, grid_step):
ctx.move_to(0, y + 0.5)
ctx.line_to(width, y + 0.5)
ctx.stroke()
rect_x = float(bounds.get("x", 0.0))
rect_y = float(bounds.get("y", 0.0))
rect_width = float(bounds.get("width", width))
rect_height = float(bounds.get("height", height))
ctx.set_source_rgba(0.97, 0.36, 0.2, 0.18)
ctx.rectangle(rect_x, rect_y, rect_width, rect_height)
ctx.fill()
ctx.set_source_rgba(0.97, 0.36, 0.2, 0.95)
ctx.set_line_width(2.0)
ctx.rectangle(rect_x + 1.0, rect_y + 1.0, max(rect_width - 2.0, 1.0), max(rect_height - 2.0, 1.0))
ctx.stroke()
if label:
label_text = f"{label} {int(rect_width)}x{int(rect_height)} @ {int(rect_x)},{int(rect_y)}"
text_x = rect_x + 8.0
text_y = max(rect_y - 10.0, 20.0)
ctx.set_source_rgba(0.08, 0.08, 0.08, 0.88)
ctx.rectangle(text_x - 6.0, text_y - 16.0, max(len(label_text) * 7.2, 120.0), 20.0)
ctx.fill()
ctx.set_source_rgba(1.0, 1.0, 1.0, 0.98)
ctx.select_font_face("Sans", 0, 0)
ctx.set_font_size(12.0)
ctx.move_to(text_x, text_y)
ctx.show_text(label_text)
except Exception:
return
def create_tab(self, title: str | None = None, cwd: str | None = None, command: str | None = None) -> dict[str, Any]:
if self.window is None:
raise RuntimeError("Window has not been created yet")
working_directory = cwd or os.path.expanduser("~")
terminal = Vte.Terminal()
terminal.set_scrollback_lines(DEFAULT_SCROLLBACK_LINES)
terminal.set_hexpand(True)
terminal.set_vexpand(True)
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_terminal_key_pressed)
terminal.add_controller(key_controller)
scroller = Gtk.ScrolledWindow()
scroller.set_child(terminal)
tab_overlay = Gtk.Overlay()
tab_overlay.set_child(scroller)
diagnostic_layer = Gtk.DrawingArea()
diagnostic_layer.set_hexpand(True)
diagnostic_layer.set_vexpand(True)
diagnostic_layer.set_can_target(False)
diagnostic_layer._diagnostic_config = None # type: ignore[attr-defined]
diagnostic_layer.set_draw_func(self._draw_tab_diagnostic_overlay)
tab_overlay.add_overlay(diagnostic_layer)
tab_overlay.set_measure_overlay(diagnostic_layer, True)
tab_overlay.set_clip_overlay(diagnostic_layer, False)
diagnostic_layer.set_visible(False)
welcome_revealer = Gtk.Revealer()
welcome_revealer.set_transition_type(Gtk.RevealerTransitionType.CROSSFADE)
welcome_revealer.set_halign(Gtk.Align.CENTER)
welcome_revealer.set_valign(Gtk.Align.CENTER)
welcome_revealer.set_can_target(False)
welcome_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
welcome_card.add_css_class("card")
welcome_card.set_margin_top(24)
welcome_card.set_margin_bottom(24)
welcome_card.set_margin_start(24)
welcome_card.set_margin_end(24)
welcome_card.set_size_request(320, -1)
welcome_title = Gtk.Label(label="Ready to control this shell")
welcome_title.add_css_class("heading")
welcome_title.set_wrap(True)
welcome_body = Gtk.Label(
label="Use MCP tools to open tabs, send commands, read output, and capture screenshots. Press any key or run a command to dismiss this panel."
)
welcome_body.add_css_class("caption")
welcome_body.set_wrap(True)
welcome_tip = Gtk.Label(label=f"Current workspace: {working_directory}")
welcome_tip.add_css_class("caption")
welcome_tip.set_wrap(True)
welcome_card.append(welcome_title)
welcome_card.append(welcome_body)
welcome_card.append(welcome_tip)
welcome_revealer.set_child(welcome_card)
tab_overlay.add_overlay(welcome_revealer)
tab_id = str(uuid.uuid4())
tab_title = self._format_tab_title(cwd=working_directory, command=command, title=title)
log_path = self.log_dir / f"{tab_id}.log"
command_events_path = self.log_dir / f"{tab_id}.commands.tsv"
history_path = self.log_dir / f"{tab_id}.history"
shell_rc_path = self.log_dir / f"{tab_id}.bashrc"
log_path.write_text("", encoding="utf-8")
command_events_path.write_text("", encoding="utf-8")
history_path.write_text("", encoding="utf-8")
page_name = tab_id
self.window.stack.add_titled(tab_overlay, page_name, tab_title)
self.window.stack.set_visible_child_name(page_name)
self.window.stack.set_visible_child(tab_overlay)
self.window.sync_header_state(tab_count=len(self.tabs) + 1)
tab = TabState(
tab_id=tab_id,
title=tab_title,
container=tab_overlay,
scroller=scroller,
terminal=terminal,
welcome_revealer=welcome_revealer,
diagnostic_layer=diagnostic_layer,
log_path=log_path,
command_events_path=command_events_path,
history_path=history_path,
shell_rc_path=shell_rc_path,
cwd=working_directory,
last_command=None,
last_command_token=None,
pending_submit_id=None,
pending_submit_text=None,
pending_submit_requested_at=None,
last_manual_submit_id=None,
last_manual_submit_text=None,
last_manual_submit_at=None,
current_execution_submission_id=None,
current_execution_command=None,
current_execution_started_at=None,
current_execution_after_sequence=None,
delegated_session_submission_id=None,
delegated_session_command=None,
delegated_session_started_at=None,
delegated_session_after_sequence=None,
)
self.tabs[tab_id] = tab
show_onboarding = len(self.tabs) == 1 and command is None
welcome_revealer.set_visible(show_onboarding)
welcome_revealer.set_reveal_child(show_onboarding)
terminal.connect("child-exited", self._on_child_exited, tab_id)
terminal.connect("window-title-changed", self._on_terminal_window_title_changed, tab_id)
self._write_shell_rc_file(tab)
self._spawn_shell(tab, cwd=working_directory)
if command:
GLib.timeout_add(150, self._feed_terminal_input, tab_id, command + "\n")
self.window.present()
return self._serialize_tab(tab)
def _command_start_marker(self, token: str) -> str:
return f"{COMMAND_START_MARKER}:{token}"
def _command_end_marker_prefix(self, token: str) -> str:
return f"{COMMAND_END_MARKER}:{token}:"
def _shell_hook_rc_content(self, tab: TabState) -> str:
transcript_file = shlex.quote(os.fspath(tab.log_path))
events_file = shlex.quote(os.fspath(tab.command_events_path))
history_file = shlex.quote(os.fspath(tab.history_path))
return f"""# Auto-generated by mcp-hal9002
[[ -f ~/.bashrc ]] && source ~/.bashrc
export HISTFILE={history_file}
export HISTSIZE=50000
export HISTFILESIZE=50000
export HISTCONTROL=
shopt -s histappend cmdhist lithist
export __MCP_HAL9002_TRANSCRIPT_FILE={transcript_file}
export __MCP_HAL9002_EVENTS_FILE={events_file}
export __MCP_HAL9002_ACTIVE_TOKEN=""
export __MCP_HAL9002_ACTIVE_STARTED_AT=""
export __MCP_HAL9002_ACTIVE_CWD=""
export __MCP_HAL9002_LAST_HISTORY_NUM=""
export __MCP_HAL9002_SEQUENCE=0
export __MCP_HAL9002_IN_HOOK=0
__mcp_hal9002_b64() {{
printf '%s' "$1" | base64 | tr -d '\\n'
}}
__mcp_hal9002_debug_trap() {{
[[ "${{__MCP_HAL9002_IN_HOOK:-0}}" = 1 ]] && return 0
case "$BASH_COMMAND" in
__mcp_hal9002_*|history*|builtin\\ history* ) return 0 ;;
esac
if [[ -z "${{__MCP_HAL9002_ACTIVE_TOKEN:-}}" ]]; then
__MCP_HAL9002_ACTIVE_TOKEN="${{EPOCHREALTIME:-0}}-$$-$RANDOM"
__MCP_HAL9002_ACTIVE_STARTED_AT="${{EPOCHREALTIME:-0}}"
__MCP_HAL9002_ACTIVE_CWD="$PWD"
printf '%s\\n' "{COMMAND_START_MARKER}:${{__MCP_HAL9002_ACTIVE_TOKEN}}" >> "$__MCP_HAL9002_TRANSCRIPT_FILE"
fi
return 0
}}
__mcp_hal9002_prompt_hook() {{
local exit_code=$?
local finished_at="${{EPOCHREALTIME:-0}}"
local hist_line=""
local hist_num=""
local command=""
local seq=""
__MCP_HAL9002_IN_HOOK=1
hist_line=$(HISTTIMEFORMAT= builtin history 1 2>/dev/null || true)
__MCP_HAL9002_IN_HOOK=0
if [[ -n "$hist_line" && "$hist_line" =~ ^[[:space:]]*([0-9]+)[[:space:]](.*)$ ]]; then
hist_num="${{BASH_REMATCH[1]}}"
command="${{BASH_REMATCH[2]}}"
fi
if [[ -n "${{__MCP_HAL9002_ACTIVE_TOKEN:-}}" && -n "$hist_num" && "$hist_num" != "${{__MCP_HAL9002_LAST_HISTORY_NUM:-}}" ]]; then
__MCP_HAL9002_LAST_HISTORY_NUM="$hist_num"
__MCP_HAL9002_SEQUENCE=$(( ${{__MCP_HAL9002_SEQUENCE:-0}} + 1 ))
seq="${{__MCP_HAL9002_SEQUENCE}}"
printf '%s\\n' "{COMMAND_END_MARKER}:${{__MCP_HAL9002_ACTIVE_TOKEN}}:$exit_code" >> "$__MCP_HAL9002_TRANSCRIPT_FILE"
printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \
"$seq" \
"${{__MCP_HAL9002_ACTIVE_TOKEN}}" \
"$exit_code" \
"${{__MCP_HAL9002_ACTIVE_STARTED_AT:-0}}" \
"$finished_at" \
"$(__mcp_hal9002_b64 "${{__MCP_HAL9002_ACTIVE_CWD:-$PWD}}")" \
"$(__mcp_hal9002_b64 "$PWD")" \
"$(__mcp_hal9002_b64 "$command")" >> "$__MCP_HAL9002_EVENTS_FILE"
fi
__MCP_HAL9002_ACTIVE_TOKEN=""
__MCP_HAL9002_ACTIVE_STARTED_AT=""
__MCP_HAL9002_ACTIVE_CWD=""
return 0
}}
trap '__mcp_hal9002_debug_trap' DEBUG
PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
"""
def _write_shell_rc_file(self, tab: TabState) -> None:
tab.shell_rc_path.write_text(self._shell_hook_rc_content(tab), encoding="utf-8")
def _spawn_shell(self, tab: TabState, cwd: str | None) -> None:
working_directory = cwd or os.path.expanduser("~")
envv = [f"{key}={value}" for key, value in os.environ.items()]
quoted_log = shlex.quote(os.fspath(tab.log_path))
script_prefix = f"exec > >(tee -a {quoted_log}) 2>&1; "
argv = [
"/bin/bash",
"-lc",
f"{script_prefix}exec /bin/bash --rcfile {shlex.quote(os.fspath(tab.shell_rc_path))} -i",
]
_success, _pid = tab.terminal.spawn_sync(
Vte.PtyFlags.DEFAULT,
working_directory,
argv,
envv,
GLib.SpawnFlags.DEFAULT,
None,
None,
None,
)
def _feed_terminal_input(self, tab_id: str, payload: str) -> bool:
tab = self.tabs.get(tab_id)
if tab is None:
return False
tab.terminal.paste_text(payload)
return False
def _looks_like_interactive_handoff(self, command: str) -> bool:
try:
tokens = shlex.split(command)
except ValueError:
tokens = command.strip().split()
if not tokens:
return False
executable = os.path.basename(tokens[0])
if executable in {"arca", "bash", "sh", "zsh", "fish", "tmux", "screen", "nu"}:
return True
if executable in {"python", "python3", "ipython", "bpython", "node"}:
return len(tokens) == 1
if executable not in {"ssh", "mosh"}:
return False
options_with_value = {
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m",
"-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
}
destination_seen = False
index = 1
while index < len(tokens):
token = tokens[index]
if not destination_seen:
if token == "--":
destination_seen = True
index += 1
continue
if token.startswith("-"):
if token in options_with_value:
index += 2
continue
if len(token) == 2 and token[0] == "-" and token in options_with_value:
index += 2
continue
index += 1
continue
destination_seen = True
index += 1
continue
return False
return destination_seen
def _on_terminal_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
_keycode: int,
_state: Gdk.ModifierType,
) -> bool:
widget = controller.get_widget()
if widget is None:
return False
tab = next((item for item in self.tabs.values() if item.terminal == widget), None)
if tab is not None:
self._dismiss_onboarding(tab)
if keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter} and tab.pending_submit_id is not None:
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
self._finalize_submission(tab, submitted_at=submitted_at)
return False
def _finalize_submission(self, tab: TabState, *, submitted_at: str) -> int:
if tab.pending_submit_id is None:
raise RuntimeError(f"tab_id={tab.tab_id} has no pending submit to finalize")
after_sequence = self._latest_command_sequence(tab)
delegated = self._looks_like_interactive_handoff(tab.pending_submit_text or "")
tab.last_manual_submit_id = tab.pending_submit_id
tab.last_manual_submit_text = tab.pending_submit_text
tab.last_manual_submit_at = submitted_at
if delegated:
tab.delegated_session_submission_id = tab.pending_submit_id
tab.delegated_session_command = tab.pending_submit_text
tab.delegated_session_started_at = submitted_at
tab.delegated_session_after_sequence = after_sequence
self._clear_current_execution(tab)
else:
tab.current_execution_submission_id = tab.pending_submit_id
tab.current_execution_command = tab.pending_submit_text
tab.current_execution_started_at = submitted_at
tab.current_execution_after_sequence = after_sequence
tab.pending_submit_id = None
tab.pending_submit_text = None
tab.pending_submit_requested_at = None
return after_sequence
def _on_terminal_window_title_changed(self, terminal: Vte.Terminal, tab_id: str) -> None:
tab = self.tabs.get(tab_id)
if tab is None:
return
terminal_title = terminal.get_window_title() or ""
terminal_title = terminal_title.strip()
if terminal_title:
self._update_tab_title(tab, self._format_tab_title(title=terminal_title))
def _draw_tab_diagnostic_overlay(
self,
area: Gtk.DrawingArea,
ctx: Any,
width: int,
height: int,
) -> None:
config = getattr(area, "_diagnostic_config", None)
self.draw_diagnostic_overlay(ctx, width, height, config)
def _on_child_exited(self, terminal: Vte.Terminal, _status: int, tab_id: str) -> None:
tab = self.tabs.get(tab_id)
if tab is not None:
with tab.log_path.open("a", encoding="utf-8") as handle:
handle.write("\n# process exited\n")
self._update_tab_title(tab, self._format_tab_title(title=f"{tab.title} exited"))
def _serialize_tab(self, tab: TabState) -> dict[str, Any]:
active = False
if self.window is not None:
visible = self.window.stack.get_visible_child()
active = visible == tab.container
return {
"tab_id": tab.tab_id,
"title": tab.title,
"active": active,
"rows": tab.terminal.get_row_count(),
"columns": tab.terminal.get_column_count(),
}
def _gtype_name(self, obj: object) -> str:
gtype = getattr(obj, "__gtype__", None)
if gtype is not None:
name = getattr(gtype, "name", None)
if name:
return str(name)
return type(obj).__name__
def _widget_bounds(self, widget: Gtk.Widget) -> dict[str, float | int] | None:
width = widget.get_width() or widget.get_allocated_width()
height = widget.get_height() or widget.get_allocated_height()
if width <= 0 or height <= 0:
return None
bounds: dict[str, float | int] = {
"x": 0.0,
"y": 0.0,
"width": width,
"height": height,
}
if self.window is None or widget == self.window:
return bounds
try:
success, rect = widget.compute_bounds(self.window)
except TypeError:
return bounds
if success:
bounds["x"] = round(rect.get_x(), 2)
bounds["y"] = round(rect.get_y(), 2)
bounds["width"] = round(rect.get_width(), 2)
bounds["height"] = round(rect.get_height(), 2)
return bounds
def _build_screenshot_metadata(
self,
*,
widget: Gtk.Widget,
target: str,
renderer: Gsk.Renderer,
surface: Gdk.Surface,
path: Path,
tab_id: str | None,
) -> dict[str, Any]:
return {
"captured_at": datetime.now().isoformat(timespec="seconds"),
"target": target,
"tab_id": tab_id,
"path": os.fspath(path),
"widget": {
"type": self._gtype_name(widget),
"name": widget.get_name(),
"visible": widget.get_visible(),
"mapped": widget.get_mapped(),
"focusable": widget.get_focusable(),
"scale_factor": widget.get_scale_factor(),
"bounds_in_window": self._widget_bounds(widget),
},
"window": {
"type": self._gtype_name(self.window) if self.window is not None else None,
"title": self.window.get_title() if self.window is not None else None,
"bounds": self._widget_bounds(self.window) if self.window is not None else None,
},
"renderer": {
"type": self._gtype_name(renderer),
"realized": renderer.is_realized(),
},
"surface": {
"type": self._gtype_name(surface),
"scale": surface.get_scale(),
},
}
def _require_tab(self, tab_id: str) -> TabState:
try:
return self.tabs[tab_id]
except KeyError as exc:
raise RuntimeError(f"Unknown tab_id: {tab_id}") from exc
def _sanitize_text(self, text: str) -> str:
cleaned = OSC_ESCAPE_RE.sub("", text)
cleaned = ANSI_ESCAPE_RE.sub("", cleaned)
cleaned = SINGLE_ESCAPE_RE.sub("", cleaned)
cleaned = cleaned.replace("\r", "")
return cleaned
def _parse_epoch(self, value: str) -> float:
return float(value.replace(",", "."))
def _parse_command_events(self, tab: TabState) -> list[dict[str, Any]]:
if not tab.command_events_path.exists():
return []
events: list[dict[str, Any]] = []
for raw_line in tab.command_events_path.read_text(encoding="utf-8").splitlines():
if not raw_line.strip():
continue
parts = raw_line.split("\t")
if len(parts) != 8:
continue
try:
started_epoch = self._parse_epoch(parts[3])
finished_epoch = self._parse_epoch(parts[4])
event = {
"sequence": int(parts[0]),
"token": parts[1],
"exit_code": int(parts[2]),
"started_epoch": started_epoch,
"finished_epoch": finished_epoch,
"started_at": datetime.fromtimestamp(started_epoch).astimezone().isoformat(timespec="seconds"),
"finished_at": datetime.fromtimestamp(finished_epoch).astimezone().isoformat(timespec="seconds"),
"duration_seconds": round(max(finished_epoch - started_epoch, 0.0), 6),
"cwd": base64.b64decode(parts[5]).decode("utf-8"),
"cwd_after": base64.b64decode(parts[6]).decode("utf-8"),
"command": base64.b64decode(parts[7]).decode("utf-8"),
}
except Exception:
continue
events.append(event)
return events
def _latest_command_event(self, tab: TabState) -> dict[str, Any] | None:
events = self._parse_command_events(tab)
return events[-1] if events else None
def _latest_command_sequence(self, tab: TabState) -> int:
latest = self._latest_command_event(tab)
return int(latest["sequence"]) if latest is not None else 0
def _manual_submit_status(self, tab: TabState, submission_id: str) -> dict[str, Any]:
if tab.last_manual_submit_id == submission_id:
return {
"tab_id": tab.tab_id,
"submission_id": submission_id,
"submitted": True,
"submitted_at": tab.last_manual_submit_at,
"written_text": tab.last_manual_submit_text,
"current_sequence": self._latest_command_sequence(tab),
}
if tab.pending_submit_id == submission_id:
return {
"tab_id": tab.tab_id,
"submission_id": submission_id,
"submitted": False,
"requested_at": tab.pending_submit_requested_at,
"written_text": tab.pending_submit_text,
"current_sequence": self._latest_command_sequence(tab),
}
raise RuntimeError(f"Unknown manual submission id for tab_id={tab.tab_id}: {submission_id}")
def _clear_current_execution(self, tab: TabState) -> None:
tab.current_execution_submission_id = None
tab.current_execution_command = None
tab.current_execution_started_at = None
tab.current_execution_after_sequence = None
def _clear_delegated_session(self, tab: TabState) -> None:
tab.delegated_session_submission_id = None
tab.delegated_session_command = None
tab.delegated_session_started_at = None
tab.delegated_session_after_sequence = None
def _delegated_session_status(self, tab: TabState) -> dict[str, Any]:
if tab.delegated_session_submission_id is None:
return {
"tab_id": tab.tab_id,
"state": "idle",
"current_sequence": self._latest_command_sequence(tab),
}
after_sequence = int(tab.delegated_session_after_sequence or 0)
current_sequence = self._latest_command_sequence(tab)
if current_sequence > after_sequence:
self._clear_delegated_session(tab)
return {
"tab_id": tab.tab_id,
"state": "idle",
"current_sequence": current_sequence,
}
return {
"tab_id": tab.tab_id,
"state": "interactive-session",
"submission_id": tab.delegated_session_submission_id,
"command": tab.delegated_session_command,
"started_at": tab.delegated_session_started_at,
"after_sequence": after_sequence,
"current_sequence": current_sequence,
}
def _running_command_status(self, tab: TabState) -> dict[str, Any]:
delegated_status = self._delegated_session_status(tab)
if delegated_status["state"] == "interactive-session":
return delegated_status
if tab.current_execution_submission_id is None:
return {
"tab_id": tab.tab_id,
"state": "idle",
"current_sequence": self._latest_command_sequence(tab),
}
after_sequence = int(tab.current_execution_after_sequence or 0)
current_sequence = self._latest_command_sequence(tab)
status = {
"tab_id": tab.tab_id,
"submission_id": tab.current_execution_submission_id,
"command": tab.current_execution_command,
"started_at": tab.current_execution_started_at,
"after_sequence": after_sequence,
"current_sequence": current_sequence,
}
if current_sequence > after_sequence:
latest = self._latest_command_event(tab)
status["state"] = "completed"
if latest is not None:
status["sequence"] = latest["sequence"]
status["finished_at"] = latest["finished_at"]
status["exit_code"] = latest["exit_code"]
return status
status["state"] = "running"
return status
def _default_screenshot_path(self, target: str, tab_id: str | None = None) -> Path:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = tab_id if tab_id else target
return self.screenshot_dir / f"{timestamp}-{suffix}.png"
def _extract_command_result(self, tab: TabState, token: str) -> dict[str, Any]:
lines = self._sanitize_text(tab.log_path.read_text(encoding="utf-8")).splitlines()
start_marker = self._command_start_marker(token)
end_prefix = self._command_end_marker_prefix(token)
start_index: int | None = None
end_index: int | None = None
exit_code: int | None = None
for index, line in enumerate(lines):
if line == start_marker:
start_index = index
end_index = None
exit_code = None
continue
if start_index is not None and line.startswith(end_prefix):
end_index = index
try:
exit_code = int(line[len(end_prefix):])
except ValueError:
exit_code = None
if start_index is None:
raise RuntimeError("No tracked command result is available for this tab yet")
if end_index is None:
raise RuntimeError("The last tracked command is still running or has not produced a completion marker yet")
text = "\n".join(lines[start_index + 1:end_index]).strip("\n")
latest = next((event for event in reversed(self._parse_command_events(tab)) if event["token"] == token), None)
if latest is None:
raise RuntimeError("Tracked command metadata is not available for this tab yet")
tab.last_command = str(latest["command"])
tab.last_command_token = token
return {
"tab_id": tab.tab_id,
"sequence": latest["sequence"],
"command": latest["command"],
"cwd": latest["cwd"],
"cwd_after": latest["cwd_after"],
"started_at": latest["started_at"],
"finished_at": latest["finished_at"],
"started_epoch": latest["started_epoch"],
"finished_epoch": latest["finished_epoch"],
"duration_seconds": latest["duration_seconds"],
"exit_code": exit_code,
"text": text,
}
def _capture_widget_to_png(self, widget: Gtk.Widget, path: Path) -> dict[str, Any]:
paintable = Gtk.WidgetPaintable.new(widget)
width = widget.get_width() or widget.get_allocated_width()
height = widget.get_height() or widget.get_allocated_height()
if width <= 0 or height <= 0:
raise RuntimeError("The widget is not ready for capture yet; try again after the window has been presented")
native = widget.get_native()
if native is None:
raise RuntimeError("The widget is not attached to a native surface yet; try again after the window has been presented")
surface = native.get_surface()
if surface is None:
raise RuntimeError("The widget does not have an associated surface yet; try again after the window has been presented")
node = None
texture = None
renderer = None
viewport = Graphene.Rect()
viewport.init(0, 0, float(width), float(height))
main_context = GLib.MainContext.default()
last_error = "Failed to snapshot the widget into a render node"
for attempt in range(4):
while main_context.pending():
main_context.iteration(False)
snapshot = Gtk.Snapshot.new()
paintable.snapshot(snapshot, float(width), float(height))
node = snapshot.to_node()
if node is None:
last_error = "Failed to snapshot the widget into a render node"
if attempt < 3:
time.sleep(0.03)
continue
raise RuntimeError(last_error)
renderer = Gsk.Renderer.new_for_surface(surface)
if renderer is None:
raise RuntimeError("Failed to create a GSK renderer for the widget surface")
if not renderer.is_realized() and not renderer.realize(surface):
raise RuntimeError("Failed to realize the GSK renderer for screenshot capture")
texture = renderer.render_texture(node, viewport)
if texture is not None:
break
renderer.unrealize()
renderer = None
last_error = "Failed to render the widget snapshot into a texture"
if attempt < 3:
time.sleep(0.03)
continue
raise RuntimeError(last_error)
if renderer is None or texture is None:
raise RuntimeError(last_error)
path.parent.mkdir(parents=True, exist_ok=True)
saved = texture.save_to_png(os.fspath(path))
metadata = self._build_screenshot_metadata(
widget=widget,
target="",
renderer=renderer,
surface=surface,
path=path,
tab_id=None,
)
renderer.unrealize()
if not saved:
raise RuntimeError(f"Failed to save screenshot to {path}")
metadata_path = path.with_suffix(".json")
metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
return {
"path": os.fspath(path),
"metadata_path": os.fspath(metadata_path),
"metadata": metadata,
"width": width,
"height": height,
}
def rpc_ping(self) -> dict[str, str]:
return {"status": "ok"}
def _visible_tab(self) -> TabState | None:
if self.window is None:
return None
visible = self.window.stack.get_visible_child()
if visible is None:
return None
return next((item for item in self.tabs.values() if item.container == visible), None)
def _quit_application(self) -> bool:
self.quit()
return False
def rpc_gui_status(self) -> dict[str, Any]:
visible_tab = self._visible_tab()
return {
"tab_count": len(self.tabs),
"active_tab_id": visible_tab.tab_id if visible_tab is not None else None,
"tab_ids": list(self.tabs.keys()),
"window_title": self.window.get_title() if self.window is not None else None,
}
def rpc_show_window(self) -> dict[str, Any]:
if self.window is None:
raise RuntimeError("Window has not been created yet")
self.window.present()
return self.rpc_gui_status()
def rpc_close_window(self) -> dict[str, Any]:
status = self.rpc_gui_status()
GLib.idle_add(self._quit_application)
status["closed"] = True
return status
def rpc_list_tabs(self) -> list[dict[str, Any]]:
return [self._serialize_tab(tab) for tab in self.tabs.values()]
def rpc_open_tab(self, title: str | None = None, cwd: str | None = None, command: str | None = None) -> dict[str, Any]:
return self.create_tab(title=title, cwd=cwd, command=command)
def rpc_focus_tab(self, tab_id: str) -> dict[str, Any]:
tab = self._require_tab(tab_id)
if self.window is None:
raise RuntimeError("Window has not been created yet")
self.window.stack.set_visible_child(tab.container)
self.window.present()
return self._serialize_tab(tab)
def rpc_exec(self, tab_id: str, command: str, newline: bool = True, auto_submit: bool = False) -> dict[str, Any]:
tab = self._require_tab(tab_id)
running_status = self._running_command_status(tab)
if running_status["state"] == "running":
raise RuntimeError(
f"tab_id={tab_id} already has a command in progress; use wait_for_running_command before sending another command"
)
if running_status["state"] == "completed":
self._clear_current_execution(tab)
if running_status["state"] == "interactive-session":
self._clear_current_execution(tab)
if tab.pending_submit_id is not None:
raise RuntimeError(
f"tab_id={tab_id} already has a pending manual submit request; press Enter in the GUI before writing another command"
)
submission_id = str(uuid.uuid4())
requested_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
payload = command
self._dismiss_onboarding(tab)
if self.window is not None:
self.window.stack.set_visible_child(tab.container)
self.window.present()
tab.terminal.grab_focus()
tab.pending_submit_id = submission_id
tab.pending_submit_text = command
tab.pending_submit_requested_at = requested_at
if auto_submit:
tab.terminal.paste_text(payload + "\n")
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
after_sequence = self._finalize_submission(tab, submitted_at=submitted_at)
return {
"tab_id": tab_id,
"written_text": command,
"awaiting_manual_submit": False,
"after_sequence": after_sequence,
"submission_id": submission_id,
"requested_at": requested_at,
"submitted_at": submitted_at,
"current_sequence": self._latest_command_sequence(tab),
"newline_ignored": False,
}
tab.terminal.paste_text(payload)
return {
"tab_id": tab_id,
"written_text": command,
"awaiting_manual_submit": True,
"after_sequence": self._latest_command_sequence(tab),
"submission_id": submission_id,
"requested_at": requested_at,
"newline_ignored": newline,
}
def rpc_manual_submit_status(self, tab_id: str, submission_id: str) -> dict[str, Any]:
tab = self._require_tab(tab_id)
return self._manual_submit_status(tab, submission_id)
def rpc_running_command_status(self, tab_id: str) -> dict[str, Any]:
tab = self._require_tab(tab_id)
return self._running_command_status(tab)
def rpc_read_tab(self, tab_id: str, last_n_lines: int = 200) -> dict[str, Any]:
tab = self._require_tab(tab_id)
lines = tab.log_path.read_text(encoding="utf-8").splitlines()
filtered_lines = [
line
for line in lines
if not line.startswith(f"{COMMAND_START_MARKER}:") and not line.startswith(f"{COMMAND_END_MARKER}:")
]
tail_lines = filtered_lines[-last_n_lines:]
text = self._sanitize_text("\n".join(tail_lines))
return {
"tab_id": tab_id,
"line_count": len(filtered_lines),
"text": text,
}
def rpc_read_last_command_result(self, tab_id: str) -> dict[str, Any]:
tab = self._require_tab(tab_id)
latest = self._latest_command_event(tab)
if latest is None:
raise RuntimeError("No tracked command result is available for this tab yet")
return self._extract_command_result(tab, str(latest["token"]))
def rpc_capture_screenshot(
self,
target: str = "window",
tab_id: str | None = None,
path: str | None = None,
diagnostic_overlay: bool = False,
) -> dict[str, Any]:
if self.window is None:
raise RuntimeError("Window has not been created yet")
target_widget: Gtk.Widget
selected_tab_id: str | None = tab_id
normalized_target = target.strip().lower()
if normalized_target == "window":
self.window.present()
target_widget = self.window
overlay_scope = "window"
elif normalized_target in {"active-tab", "active_tab", "tab", "terminal", "vte"}:
if selected_tab_id is None:
visible = self.window.stack.get_visible_child()
if visible is None:
raise RuntimeError("No active tab is available")
tab = next((item for item in self.tabs.values() if item.container == visible), None)
if tab is None:
raise RuntimeError("Unable to resolve the active tab")
else:
tab = self._require_tab(selected_tab_id)
self.window.stack.set_visible_child(tab.container)
self.window.present()
target_widget = tab.terminal
selected_tab_id = tab.tab_id
normalized_target = "tab"
overlay_scope = "terminal"
elif normalized_target in {"tab-container", "tab_container", "scroller"}:
if selected_tab_id is None:
visible = self.window.stack.get_visible_child()
if visible is None:
raise RuntimeError("No active tab is available")
tab = next((item for item in self.tabs.values() if item.container == visible), None)
if tab is None:
raise RuntimeError("Unable to resolve the active tab")
else:
tab = self._require_tab(selected_tab_id)
self.window.stack.set_visible_child(tab.container)
self.window.present()
target_widget = tab.container
selected_tab_id = tab.tab_id
normalized_target = "tab-container"
overlay_scope = "tab-container"
else:
raise RuntimeError("target must be one of: window, tab, tab-container")
screenshot_path = Path(path) if path else self._default_screenshot_path(normalized_target, tab_id=selected_tab_id)
try:
result = self._capture_widget_to_png(target_widget, screenshot_path)
except RuntimeError:
if normalized_target != "window":
raise
fallback_widget = self.window.get_child()
if fallback_widget is None or fallback_widget is target_widget:
raise
target_widget = fallback_widget
result = self._capture_widget_to_png(target_widget, screenshot_path)
result["metadata"]["target"] = normalized_target
result["metadata"]["tab_id"] = selected_tab_id
overlay_applied = False
if diagnostic_overlay:
overlay_label = f"{normalized_target} {self._gtype_name(target_widget)}"
self._annotate_screenshot_with_diagnostics(
screenshot_path,
width=result["width"],
height=result["height"],
label=overlay_label,
bounds_in_window=result["metadata"]["widget"].get("bounds_in_window"),
)
overlay_applied = True
result["metadata"]["diagnostic_overlay"] = {
"requested": diagnostic_overlay,
"applied": overlay_applied,
"scope": overlay_scope,
"mode": "postprocess-cairo",
}
Path(result["metadata_path"]).write_text(json.dumps(result["metadata"], indent=2), encoding="utf-8")
result["target"] = normalized_target
if selected_tab_id is not None:
result["tab_id"] = selected_tab_id
return result
def rpc_close_tab(self, tab_id: str) -> dict[str, Any]:
tab = self._require_tab(tab_id)
if self.window is None:
raise RuntimeError("Window has not been created yet")
self.window.stack.remove(tab.container)
del self.tabs[tab_id]
if tab.log_path.exists():
tab.log_path.unlink()
if not self.tabs:
self.create_tab(title="shell")
elif self.window is not None:
self.window.sync_header_state(tab_count=len(self.tabs))
return {"closed": tab_id}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="mcp-hal9002 GTK4/VTE terminal workspace controllable through a local Unix socket")
parser.add_argument("--socket", type=Path, default=default_socket_path(), help="Unix socket path for the local control plane")
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
app = TerminalApp(socket_path=args.socket)
app.run([])
if __name__ == "__main__":
main()