diff --git a/build.zig b/build.zig
index 8bc6245..4118fe3 100644
--- a/build.zig
+++ b/build.zig
@@ -96,6 +96,11 @@ pub fn build(b: *std.Build) void {
const smoke_cmd = b.addRunArtifact(smoke_tests);
const smoke_step = b.step("v1-smoke", "Run cheap v1 end-to-end fixture suite");
smoke_step.dependOn(&smoke_cmd.step);
+
+ const terminal_e2e_cmd = b.addSystemCommand(&.{ "python3", "tools/terminal_e2e.py" });
+ terminal_e2e_cmd.addArtifactArg(exe);
+ const terminal_e2e_step = b.step("terminal-e2e", "Run PTY-backed browser-terminal E2E checks");
+ terminal_e2e_step.dependOn(&terminal_e2e_cmd.step);
}
fn addRunProfileStep(b: *std.Build, profile: Profile, exe: *std.Build.Step.Compile) void {
diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py
new file mode 100755
index 0000000..092a82c
--- /dev/null
+++ b/tools/terminal_e2e.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+"""PTY-backed terminal E2E harness for mim.
+
+Runs a tiny high-signal scenario through a real pseudo-terminal and writes
+browser-viewable terminal artifacts. No third-party dependencies.
+"""
+from __future__ import annotations
+
+import argparse
+import html
+import os
+import pty
+import select
+import signal
+import shutil
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+ESC = 0x1B
+
+
+def visible_controls(data: bytes) -> str:
+ out: list[str] = []
+ text = data.decode("utf-8", errors="replace")
+ for ch in text:
+ code = ord(ch)
+ if ch == "\r":
+ out.append("␍")
+ elif ch == "\n":
+ out.append("␊\n")
+ elif ch == "\x1b":
+ out.append("␛")
+ elif ch == "\t":
+ out.append("⇥")
+ elif code < 0x20:
+ out.append("␀")
+ else:
+ out.append(ch)
+ return "".join(out)
+
+
+class TerminalGrid:
+ def __init__(self, width: int, height: int) -> None:
+ self.width = width
+ self.height = height
+ self.rows = [[" " for _ in range(width)] for _ in range(height)]
+ self.row = 0
+ self.col = 0
+
+ def clear(self) -> None:
+ self.rows = [[" " for _ in range(self.width)] for _ in range(self.height)]
+ self.row = 0
+ self.col = 0
+
+ def newline(self) -> None:
+ self.row += 1
+ if self.row >= self.height:
+ self.rows.pop(0)
+ self.rows.append([" " for _ in range(self.width)])
+ self.row = self.height - 1
+
+ def put_char(self, ch: str) -> None:
+ if ch == "\r":
+ self.col = 0
+ return
+ if ch == "\n":
+ self.newline()
+ return
+ if self.col >= self.width:
+ self.col = 0
+ self.newline()
+ if 0 <= self.row < self.height and 0 <= self.col < self.width:
+ self.rows[self.row][self.col] = ch
+ self.col += 1
+
+ def feed(self, data: bytes) -> None:
+ i = 0
+ text = data.decode("utf-8", errors="replace")
+ while i < len(text):
+ ch = text[i]
+ if ch == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
+ j = i + 2
+ while j < len(text) and not ("@" <= text[j] <= "~"):
+ j += 1
+ if j >= len(text):
+ break
+ command = text[j]
+ params = text[i + 2 : j]
+ if command == "H":
+ self.row = 0
+ self.col = 0
+ elif command == "J" and params.endswith("2"):
+ self.clear()
+ # SGR and unknown CSI are intentionally ignored for semantic grid.
+ i = j + 1
+ continue
+ self.put_char(ch)
+ i += 1
+
+ def lines(self) -> list[str]:
+ return ["".join(row).rstrip() for row in self.rows]
+
+
+def read_available(fd: int, timeout: float = 0.12) -> bytes:
+ chunks: list[bytes] = []
+ deadline = time.monotonic() + timeout
+ while True:
+ remaining = max(0.0, deadline - time.monotonic())
+ ready, _, _ = select.select([fd], [], [], remaining)
+ if not ready:
+ break
+ try:
+ chunk = os.read(fd, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ chunks.append(chunk)
+ deadline = time.monotonic() + 0.04
+ return b"".join(chunks)
+
+
+def spawn_under_pty(argv: list[str], width: int, height: int) -> tuple[int, int]:
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.environ["TERM"] = "xterm-256color"
+ try:
+ import fcntl
+ import struct
+ import termios
+
+ winsize = struct.pack("HHHH", height, width, 0, 0)
+ fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
+ except Exception:
+ pass
+ os.execv(argv[0], argv)
+ return pid, fd
+
+
+def svg_for_lines(lines: list[str], width: int, height: int) -> str:
+ cell_w = 9
+ cell_h = 18
+ pad = 12
+ svg_w = pad * 2 + width * cell_w
+ svg_h = pad * 2 + height * cell_h
+ body = []
+ for idx, line in enumerate(lines[:height]):
+ body.append(
+ f'