summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/e2e_harness.zig278
-rw-r--r--test/snapshot.zig553
-rw-r--r--test/snapshots/boot.golden62
-rw-r--r--test/snapshots/boot.snap8
-rw-r--r--test/snapshots/dump.actual63
-rw-r--r--test/snapshots/dump.golden64
-rw-r--r--test/snapshots/dump.snap18
-rw-r--r--test/snapshots/edit.actual62
-rw-r--r--test/snapshots/edit.golden62
-rw-r--r--test/snapshots/edit.snap15
-rw-r--r--test/snapshots/exec.golden124
-rw-r--r--test/snapshots/exec.snap46
-rw-r--r--test/snapshots/fileedit.golden116
-rw-r--r--test/snapshots/fileedit.snap40
-rw-r--r--test/snapshots/images.golden124
-rw-r--r--test/snapshots/images.snap29
-rw-r--r--test/snapshots/load.golden62
-rw-r--r--test/snapshots/load.snap27
-rw-r--r--test/snapshots/look-dir.actual31
-rw-r--r--test/snapshots/look-dir.golden31
-rw-r--r--test/snapshots/look-dir.snap20
-rw-r--r--test/snapshots/look-file.golden62
-rw-r--r--test/snapshots/look-file.snap19
-rw-r--r--test/snapshots/modal.golden372
-rw-r--r--test/snapshots/modal.snap53
-rw-r--r--test/snapshots/scroll.golden186
-rw-r--r--test/snapshots/scroll.snap31
-rw-r--r--test/snapshots/syntax.actual124
-rw-r--r--test/snapshots/syntax.golden124
-rw-r--r--test/snapshots/syntax.snap27
-rw-r--r--test/snapshots/tag.golden217
-rw-r--r--test/snapshots/tag.snap41
-rw-r--r--test/snapshots/theme.golden186
-rw-r--r--test/snapshots/theme.snap24
-rw-r--r--test/snapshots/tty.golden93
-rw-r--r--test/snapshots/tty.snap16
-rw-r--r--test/snapshots/ttyonly.golden62
-rw-r--r--test/snapshots/ttyonly.snap11
-rw-r--r--test/snapshots/tutor.golden31
-rw-r--r--test/snapshots/tutor.snap8
-rw-r--r--test/snapshots/windowops.golden372
-rw-r--r--test/snapshots/windowops.snap53
42 files changed, 3947 insertions, 0 deletions
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
new file mode 100644
index 00000000..6354aa4b
--- /dev/null
+++ b/test/e2e_harness.zig
@@ -0,0 +1,278 @@
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const ghostty_vt = @import("ghostty-vt");
+
+extern "c" fn forkpty(
+ amaster: *c_int,
+ name: ?[*:0]u8,
+ termp: ?*const anyopaque,
+ winp: ?*const posix.winsize,
+) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+fn nowMs() i64 {
+ var ts: linux.timespec = undefined;
+ _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
+ return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000);
+}
+
+/// E2E test harness: forks the app in a pty and parses its output with a
+/// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString),
+/// not raw byte streams — so they're decoupled from how the app renders
+/// (vaxis diff-rendering, repaints, etc). The grid is always current after
+/// `pump`/`waitFor`; no setWinsize-to-force-repaint hacks needed.
+///
+/// Input is sent as raw bytes to the pty (the app's vaxis parses them). The
+/// harness terminal only reads output.
+pub const Harness = struct {
+ gpa: std.mem.Allocator,
+ master: c_int,
+ pid: posix.pid_t,
+ /// heap-allocated so its address is stable: vtStream()'s handler holds a
+ /// pointer back into the Terminal, so it must not move (same reason main.zig
+ /// heap-allocates Term). Freed in deinit.
+ term: *ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+ rows: u16,
+ cols: u16,
+ /// when true, print the captured screen state after each pump/waitFor and on
+ /// every assertion, so live test runs can be inspected (zig build test -Dtrace).
+ trace: bool = false,
+ /// every raw byte the app has emitted (accumulated in pump). Lets tests assert
+ /// on control sequences the emulator consumes and never renders (e.g. OSC 52
+ /// clipboard writes). gpa-owned; freed in deinit.
+ raw: std.ArrayList(u8) = .empty,
+
+ pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness {
+ return initArgs(gpa, exe, rows, cols, null);
+ }
+
+ /// Like init, but passes one extra CLI arg to the app (e.g. "--tty").
+ pub fn initArgs(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, arg: ?[*:0]const u8) !Harness {
+ var argv: [2]?[*:0]const u8 = .{ null, null };
+ if (arg) |a| argv[0] = a;
+ return initArgv(gpa, exe, rows, cols, argv[0..(if (arg != null) @as(usize, 1) else 0)]);
+ }
+
+ /// Like init, but passes extra CLI args (e.g. "-l", "dump.zon").
+ pub fn initArgv(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16, args: []const ?[*:0]const u8) !Harness {
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ var argv: [8:null]?[*:0]const u8 = @splat(null);
+ argv[0] = exe;
+ for (args, 0..) |a, i| argv[i + 1] = a;
+ _ = execvp(exe, &argv);
+ _exit(127);
+ }
+ const term = try gpa.create(ghostty_vt.Terminal);
+ errdefer gpa.destroy(term);
+ term.* = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 1024 * 1024,
+ });
+ errdefer term.deinit(gpa);
+ // vtStream() captures a pointer to `term`; term is heap-allocated so the
+ // pointer stays valid for the life of the harness.
+ const stream = term.vtStream();
+ return .{
+ .gpa = gpa,
+ .master = master,
+ .pid = pid,
+ .term = term,
+ .stream = stream,
+ .rows = rows,
+ .cols = cols,
+ };
+ }
+
+ pub fn deinit(self: *Harness) void {
+ posix.kill(self.pid, posix.SIG.KILL) catch {};
+ _ = linux.close(self.master);
+ self.stream.deinit();
+ self.raw.deinit(self.gpa);
+ self.term.deinit(self.gpa);
+ self.gpa.destroy(self.term);
+ }
+
+ /// Write raw bytes to the pty (input to the app: keystrokes, mouse events).
+ pub fn send(self: *Harness, bytes: []const u8) !void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.WriteFailed;
+ }
+ off += @intCast(n);
+ }
+ }
+
+ /// Read pty output and feed it to our ghostty terminal for `ms` ms. After
+ /// this, the grid reflects everything the app rendered so far.
+ pub fn pump(self: *Harness, ms: i64) !void {
+ const deadline = nowMs() + ms;
+ var buf: [4096]u8 = undefined;
+ while (nowMs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 50) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(self.master, &buf) catch break;
+ if (n == 0) break;
+ self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.stream.nextSlice(buf[0..n]);
+ }
+ }
+ if (self.trace) self.traceScreen("pump");
+ }
+
+ /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows
+ /// to the new size). Harness terminal first so it's ready for the app's
+ /// re-render at the new size.
+ pub fn resize(self: *Harness, rows: u16, cols: u16) !void {
+ self.rows = rows;
+ self.cols = cols;
+ self.term.resize(self.gpa, cols, rows) catch {};
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+
+ /// The full viewport text (plainString), newline-joined rows. Caller frees.
+ pub fn screenText(self: *Harness) ![]const u8 {
+ return self.term.plainString(self.gpa);
+ }
+
+ /// Is `needle` present anywhere on the current screen?
+ pub fn contains(self: *Harness, needle: []const u8) bool {
+ const text = self.screenText() catch return false;
+ defer self.gpa.free(text);
+ return std.mem.indexOf(u8, text, needle) != null;
+ }
+
+ /// Wait up to `ms` for `needle` to appear on screen, pumping bytes the
+ /// whole time. Replaces pollFor + setWinsize-repaint: the grid is checked
+ /// directly each iteration.
+ pub fn waitFor(self: *Harness, needle: []const u8, ms: i64) !bool {
+ {
+ const text = try self.screenText();
+ defer self.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) {
+ if (self.trace) self.traceScreen("waitFor hit");
+ return true;
+ }
+ }
+ const deadline = nowMs() + ms;
+ var buf: [4096]u8 = undefined;
+ while (nowMs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 50) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(self.master, &buf) catch break;
+ if (n == 0) break;
+ self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
+ self.stream.nextSlice(buf[0..n]);
+ }
+ const text = try self.screenText();
+ defer self.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) {
+ if (self.trace) self.traceScreen("waitFor hit");
+ return true;
+ }
+ }
+ if (self.trace) self.traceScreen("waitFor miss");
+ return false;
+ }
+
+ /// The visual cursor position (viewport coords): { x, y }.
+ pub fn cursor(self: *Harness) struct { x: u16, y: u16 } {
+ return .{
+ .x = self.term.screens.active.cursor.x,
+ .y = self.term.screens.active.cursor.y,
+ };
+ }
+
+ /// True if the shell is at a prompt (OSC 133 semantic prompt). Requires
+ /// shell integration; false if the shell is outputting or has no integration.
+ pub fn atPrompt(self: *Harness) bool {
+ return self.term.cursorIsAtPrompt();
+ }
+
+ /// Print the screen grid (clean text, not raw bytes) for debugging on
+ /// failure. Also shows the cursor position and screen size.
+ pub fn dump(self: *Harness, msg: []const u8) void {
+ const text = self.screenText() catch {
+ std.debug.print("E2E FAIL: {s}\n(could not read screen)\n", .{msg});
+ return;
+ };
+ defer self.gpa.free(text);
+ const c = self.cursor();
+ std.debug.print("E2E FAIL: {s}\nscreen {d}x{d} cursor=({d},{d}):\n{s}\n", .{
+ msg, self.cols, self.rows, c.x, c.y, text,
+ });
+ }
+
+ /// trace-mode screen snapshot: print the current grid + cursor + size to
+ /// stderr (always, not just on failure). Bounded by a header so the live
+ /// stream of snapshots is easy to scan.
+ pub fn traceScreen(self: *Harness, tag: []const u8) void {
+ const text = self.screenText() catch return;
+ defer self.gpa.free(text);
+ const c = self.cursor();
+ std.debug.print("\n[trace {s}] screen {d}x{d} cursor=({d},{d}):\n{s}\n[/trace]\n", .{
+ tag, self.cols, self.rows, c.x, c.y, text,
+ });
+ }
+
+ // ---- assertions (dump on failure, return error.ExpectFailed) ----
+
+ pub fn expectWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void {
+ if (!try self.waitFor(needle, ms)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (!self.contains(needle)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectNotContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (self.contains(needle)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ /// Assert a byte sequence appears anywhere in the app's raw output so far.
+ /// For control sequences the emulator swallows and never renders (OSC 52 etc.).
+ pub fn expectRawContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (std.mem.indexOf(u8, self.raw.items, needle) == null) {
+ std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle });
+ return error.ExpectFailed;
+ }
+ }
+};
+
+/// Write a file (helper for test setup, raw linux syscalls).
+pub fn writeFile(path: [*:0]const u8, data: []const u8) !void {
+ const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var off: usize = 0;
+ while (off < data.len) {
+ const w = linux.write(fd, data[off..].ptr, data.len - off);
+ const sw: isize = @bitCast(w);
+ if (sw <= 0) break;
+ off += @intCast(sw);
+ }
+}
diff --git a/test/snapshot.zig b/test/snapshot.zig
new file mode 100644
index 00000000..c1bf03c0
--- /dev/null
+++ b/test/snapshot.zig
@@ -0,0 +1,553 @@
+// pardes-snap: drive a pardes binary with a scripted input trace in a pty and
+// capture the rendered grid at named sync points. The same script run against
+// the prototype and the rewrite must produce byte-identical captures — this is
+// the old-vs-new parity oracle for the rewrite.
+//
+// zig build snap run all snapshots/*.snap, diff goldens
+// zig build snap -- --update regenerate goldens from this binary
+// zig build snap -- snapshots/boot.snap one script
+//
+// Script commands (line-oriented, # comments):
+// file <name> <content> create file in the script's cwd (before start)
+// lines <name> <n> create file with n numbered lines
+// dirmk <name> create a subdirectory
+// start <rows> <cols> [arg] fork the app in a pty (optional extra CLI arg)
+// wait <ms> <needle...> pump until needle appears on the grid (fails hard)
+// settle <ms> pump for a fixed duration
+// stable <quiet_ms> <timeout_ms> pump until the grid stops changing
+// text <literal> send literal bytes
+// send <escaped> send with \e \r \n \t \\ \xNN escapes
+// key <names...> enter esc tab bs space up down left right home end
+// pgup pgdn c-<ch> a-<ch> (after esc: settle >=600ms,
+// or vaxis parses the next byte as alt-<ch>)
+// press|release <left|middle|right> <col> <row> SGR mouse, 1-based
+// drag <btn> <col> <row> motion with button held
+// motion <col> <row> button-less motion (hover)
+// wheel <up|down> <col> <row>
+// resize <rows> <cols>
+// snap <label> capture grid text + cursor
+// snapstyle <label> capture per-cell style runs (fg,bg,attrs)
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const ghostty_vt = @import("ghostty-vt");
+const eh = @import("e2e_harness.zig");
+
+extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+
+const SNAP_BASE = "/tmp/pardes-snap";
+const gpa = std.heap.page_allocator;
+
+pub fn main(init: std.process.Init) !void {
+ var arena_state = std.heap.ArenaAllocator.init(gpa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+
+ // argv: exe-under-test, then flags/scripts. All paths made absolute up
+ // front because runScript chdirs into a per-script workdir.
+ var cwd_buf: [4096]u8 = undefined;
+ const cwd_rc: isize = @bitCast(linux.getcwd(&cwd_buf, cwd_buf.len));
+ if (cwd_rc <= 0) fatal("getcwd failed", .{});
+ const orig_cwd = cwd_buf[0..@intCast(cwd_rc - 1)];
+
+ var exe: ?[]const u8 = null;
+ var update = false;
+ var scripts: std.ArrayList([]const u8) = .empty;
+ for (args[1..]) |a| {
+ if (std.mem.eql(u8, a, "--update")) {
+ update = true;
+ } else if (exe == null) {
+ exe = try absPath(arena, orig_cwd, a);
+ } else {
+ try scripts.append(arena, try absPath(arena, orig_cwd, a));
+ }
+ }
+ if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update]", .{});
+ const exe_z = try arena.dupeZ(u8, exe.?);
+
+ if (scripts.items.len == 0) {
+ try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"), &scripts);
+ std.mem.sort([]const u8, scripts.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+ }
+ if (scripts.items.len == 0) fatal("no .snap scripts found", .{});
+
+ var failed: usize = 0;
+ for (scripts.items) |script_path| {
+ const stem = std.fs.path.stem(script_path);
+ const base_path = script_path[0 .. script_path.len - ".snap".len];
+ const golden_path = try std.fmt.allocPrint(arena, "{s}.golden", .{base_path});
+ const out = runScript(arena, exe_z, script_path, stem) catch |e| {
+ std.debug.print("FAIL {s}: script error {s}\n", .{ stem, @errorName(e) });
+ failed += 1;
+ continue;
+ };
+ if (update) {
+ try eh.writeFile(try arena.dupeZ(u8, golden_path), out);
+ std.debug.print("UPDATED {s} ({d} bytes)\n", .{ stem, out.len });
+ } else {
+ const golden = readFileAlloc(arena, golden_path) catch {
+ std.debug.print("FAIL {s}: no golden (run with -- --update)\n", .{stem});
+ failed += 1;
+ continue;
+ };
+ if (std.mem.eql(u8, golden, out)) {
+ std.debug.print("PASS {s}\n", .{stem});
+ } else {
+ const actual_path = try std.fmt.allocPrint(arena, "{s}.actual", .{base_path});
+ try eh.writeFile(try arena.dupeZ(u8, actual_path), out);
+ std.debug.print("FAIL {s}: differs from golden (actual written to {s})\n", .{ stem, actual_path });
+ printFirstDiff(golden, out);
+ failed += 1;
+ }
+ }
+ }
+ if (failed > 0) {
+ std.debug.print("{d}/{d} snapshot scripts FAILED\n", .{ failed, scripts.items.len });
+ std.process.exit(1);
+ }
+ std.debug.print("all {d} snapshot scripts ok\n", .{scripts.items.len});
+}
+
+fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const u8, stem: []const u8) ![]u8 {
+ const src = try readFileAlloc(arena, script_path);
+
+ // Fixed, recreated-per-run workdir: its PATH shows up in pane tags, so it
+ // must be identical across runs and across old/new binaries.
+ const base = try std.fmt.allocPrint(arena, "{s}/{s}", .{ SNAP_BASE, stem });
+ rmrf(base);
+ try mkdir(arena, SNAP_BASE, true);
+ try mkdir(arena, base, false);
+ const home = try std.fmt.allocPrint(arena, "{s}/home", .{base});
+ const work = try std.fmt.allocPrint(arena, "{s}/cwd", .{base});
+ try mkdir(arena, home, false);
+ try mkdir(arena, work, false);
+ // deterministic shell: fixed prompt, no history, no user rc leakage
+ try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/.bashrc", .{home}, 0), "PS1='$ '\nHISTFILE=\n");
+ _ = setenv("HOME", try arena.dupeZ(u8, home), 1);
+ _ = setenv("TERM", "xterm-256color", 1);
+ _ = setenv("LC_ALL", "C", 1);
+ if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed;
+
+ var out: std.ArrayList(u8) = .empty;
+ var h: ?eh.Harness = null;
+ defer if (h) |*hp| hp.deinit();
+
+ var lines_it = std.mem.splitScalar(u8, src, '\n');
+ var lineno: usize = 0;
+ while (lines_it.next()) |raw_line| {
+ lineno += 1;
+ const line = std.mem.trim(u8, raw_line, " \t\r");
+ if (line.len == 0 or line[0] == '#') continue;
+ var tok = std.mem.tokenizeScalar(u8, line, ' ');
+ const cmd = tok.next().?;
+ errdefer std.debug.print(" at {s}:{d}: {s}\n", .{ script_path, lineno, line });
+
+ if (std.mem.eql(u8, cmd, "file")) {
+ const name = tok.next() orelse return error.BadScript;
+ try eh.writeFile(try arena.dupeZ(u8, name), try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "lines")) {
+ const name = tok.next() orelse return error.BadScript;
+ const n = try std.fmt.parseInt(usize, tok.next() orelse return error.BadScript, 10);
+ var buf: std.ArrayList(u8) = .empty;
+ for (1..n + 1) |i| try buf.appendSlice(arena, try std.fmt.allocPrint(arena, "line {d}\n", .{i}));
+ try eh.writeFile(try arena.dupeZ(u8, name), buf.items);
+ } else if (std.mem.eql(u8, cmd, "ppmdiag")) {
+ // the 64x64 diagonal-split test PPM (white/blue) from the old e2e
+ const name = tok.next() orelse return error.BadScript;
+ var ppm: std.ArrayList(u8) = .empty;
+ try ppm.appendSlice(arena, "P6\n64 64\n255\n");
+ var py: usize = 0;
+ while (py < 64) : (py += 1) {
+ var px: usize = 0;
+ while (px < 64) : (px += 1)
+ try ppm.appendSlice(arena, if (px + py < 64) &[_]u8{ 255, 255, 255 } else &[_]u8{ 0, 0, 255 });
+ }
+ try eh.writeFile(try arena.dupeZ(u8, name), ppm.items);
+ } else if (std.mem.eql(u8, cmd, "dirmk")) {
+ try mkdir(arena, tok.next() orelse return error.BadScript, false);
+ } else if (std.mem.eql(u8, cmd, "start")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ var extra: [6]?[*:0]const u8 = @splat(null);
+ var nextra: usize = 0;
+ while (tok.next()) |a| : (nextra += 1) {
+ if (nextra >= extra.len) return error.BadScript;
+ extra[nextra] = (try arena.dupeZ(u8, a)).ptr;
+ }
+ if (h) |*hp| hp.deinit(); // scripts may restart (e.g. dump then -l)
+ h = try eh.Harness.initArgv(gpa, exe_z.ptr, rows, cols, extra[0..nextra]);
+ } else if (std.mem.eql(u8, cmd, "snapfile")) {
+ const label = tok.next() orelse return error.BadScript;
+ const contents = try readFileAlloc(arena, tok.rest());
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== file {s}\n", .{label}));
+ // elide base64 payloads: they encode raw pty byte HISTORY, whose
+ // micro-timing (prompt redraw cycles) legitimately differs between
+ // implementations; the cleaned text fields are the contract.
+ var flines = std.mem.splitScalar(u8, contents, '\n');
+ while (flines.next()) |fl| {
+ if (std.mem.indexOf(u8, fl, "_b64 = \"") != null) {
+ const eq = std.mem.indexOf(u8, fl, "=").?;
+ try out.appendSlice(arena, fl[0 .. eq + 1]);
+ try out.appendSlice(arena, " <elided>\n");
+ } else {
+ try out.appendSlice(arena, fl);
+ try out.append(arena, '\n');
+ }
+ }
+ try out.appendSlice(arena, "== end file\n");
+ } else if (std.mem.eql(u8, cmd, "wait")) {
+ const ms = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try live(&h).expectWaitFor(tok.rest(), ms, "snapshot wait");
+ } else if (std.mem.eql(u8, cmd, "settle")) {
+ try live(&h).pump(try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10));
+ } else if (std.mem.eql(u8, cmd, "stable")) {
+ const quiet = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ const timeout = try std.fmt.parseInt(i64, tok.next() orelse return error.BadScript, 10);
+ try waitStable(live(&h), quiet, timeout);
+ } else if (std.mem.eql(u8, cmd, "text")) {
+ try live(&h).send(tok.rest());
+ } else if (std.mem.eql(u8, cmd, "send")) {
+ try live(&h).send(try unescape(arena, tok.rest()));
+ } else if (std.mem.eql(u8, cmd, "key")) {
+ while (tok.next()) |name| try live(&h).send(keyBytes(name) orelse return error.BadKey);
+ } else if (std.mem.eql(u8, cmd, "press") or std.mem.eql(u8, cmd, "release") or std.mem.eql(u8, cmd, "drag")) {
+ const btn = buttonCode(tok.next() orelse return error.BadScript) orelse return error.BadScript;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const b: u16 = if (std.mem.eql(u8, cmd, "drag")) btn + 32 else btn;
+ const fin: u8 = if (std.mem.eql(u8, cmd, "release")) 'm' else 'M';
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}{c}", .{ b, col, row, fin }));
+ } else if (std.mem.eql(u8, cmd, "motion")) {
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<35;{d};{d}M", .{ col, row }));
+ } else if (std.mem.eql(u8, cmd, "wheel")) {
+ const dir = tok.next() orelse return error.BadScript;
+ const b: u16 = if (std.mem.eql(u8, dir, "up")) 64 else 65;
+ const col = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const row = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).send(try std.fmt.allocPrint(arena, "\x1b[<{d};{d};{d}M", .{ b, col, row }));
+ } else if (std.mem.eql(u8, cmd, "resize")) {
+ const rows = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ const cols = try std.fmt.parseInt(u16, tok.next() orelse return error.BadScript, 10);
+ try live(&h).resize(rows, cols);
+ } else if (std.mem.eql(u8, cmd, "snap")) {
+ try snapText(arena, &out, live(&h), tok.rest());
+ } else if (std.mem.eql(u8, cmd, "snapstyle")) {
+ try snapStyle(arena, &out, live(&h), tok.rest());
+ } else {
+ std.debug.print("unknown command {s}\n", .{cmd});
+ return error.BadScript;
+ }
+ }
+ return out.items;
+}
+
+fn live(h: *?eh.Harness) *eh.Harness {
+ if (h.*) |*v| return v;
+ fatal("script used the app before `start`", .{});
+}
+
+/// Pump until the grid is unchanged for `quiet` ms (bounded by `timeout`).
+/// The one sync primitive that needs no needle — used before every snap so
+/// old and new binaries are compared at rest, not mid-repaint.
+fn waitStable(h: *eh.Harness, quiet: i64, timeout: i64) !void {
+ var total: i64 = 0;
+ var prev = try h.screenText();
+ defer h.gpa.free(prev);
+ while (total < timeout) {
+ try h.pump(quiet);
+ total += quiet;
+ const cur = try h.screenText();
+ if (std.mem.eql(u8, prev, cur)) {
+ h.gpa.free(cur);
+ return;
+ }
+ h.gpa.free(prev);
+ prev = cur;
+ }
+ return error.NeverStable;
+}
+
+fn snapText(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ const text = try h.screenText();
+ defer h.gpa.free(text);
+ const c = h.cursor();
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== snap {s} grid={d}x{d} cursor={d},{d}\n", .{ label, h.cols, h.rows, c.x, c.y }));
+ var it = std.mem.splitScalar(u8, text, '\n');
+ var row: usize = 0;
+ while (row < h.rows) : (row += 1) {
+ const line = it.next() orelse "";
+ try out.append(arena, '|');
+ try out.appendSlice(arena, std.mem.trimEnd(u8, line, " "));
+ try out.append(arena, '\n');
+ }
+}
+
+/// Per-row style runs from the harness emulator: `|y: x0-x1 fg,bg,attrs ...`.
+/// fg/bg: d (default) | pN (palette) | #rrggbb. attrs: b f i k r v x + u U ~ . -
+/// for underline single/double/curly/dotted/dashed.
+fn snapStyle(arena: std.mem.Allocator, out: *std.ArrayList(u8), h: *eh.Harness, label: []const u8) !void {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "== style {s}\n", .{label}));
+ var y: u16 = 0;
+ while (y < h.rows) : (y += 1) {
+ try out.appendSlice(arena, try std.fmt.allocPrint(arena, "|{d}:", .{y}));
+ var run_buf: [64]u8 = undefined;
+ var key_buf: [64]u8 = undefined;
+ var run_key: []const u8 = "";
+ var run_start: u16 = 0;
+ var x: u16 = 0;
+ while (x <= h.cols) : (x += 1) {
+ const key: []const u8 = if (x == h.cols) "\x00end" else cellKey(h, x, y, &key_buf);
+ if (x == 0) {
+ run_key = try arena.dupe(u8, key);
+ continue;
+ }
+ if (std.mem.eql(u8, key, run_key)) continue;
+ try out.appendSlice(arena, try std.fmt.bufPrint(&run_buf, " {d}-{d} ", .{ run_start, x - 1 }));
+ try out.appendSlice(arena, run_key);
+ run_key = try arena.dupe(u8, key);
+ run_start = x;
+ }
+ try out.append(arena, '\n');
+ }
+}
+
+fn cellKey(h: *eh.Harness, x: u16, y: u16, buf: *[64]u8) []const u8 {
+ const ci = h.term.screens.active.pages.getCell(.{ .viewport = .{ .x = x, .y = y } }) orelse return "d,d,";
+ if (ci.cell.wide == .spacer_tail) return "d,d,";
+ const style = ci.style();
+ var n: usize = 0;
+ var tmp: [16]u8 = undefined;
+ const fg = colorStr(style.fg_color, &tmp);
+ @memcpy(buf[n..][0..fg.len], fg);
+ n += fg.len;
+ buf[n] = ',';
+ n += 1;
+ var tmp2: [16]u8 = undefined;
+ const bg: []const u8 = switch (ci.cell.content_tag) {
+ .bg_color_palette => std.fmt.bufPrint(&tmp2, "p{d}", .{ci.cell.content.color_palette.data}) catch "?",
+ .bg_color_rgb => blk: {
+ const rgb = ci.cell.content.color_rgb;
+ break :blk std.fmt.bufPrint(&tmp2, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?";
+ },
+ else => colorStr(style.bg_color, &tmp2),
+ };
+ @memcpy(buf[n..][0..bg.len], bg);
+ n += bg.len;
+ buf[n] = ',';
+ n += 1;
+ if (style.flags.bold) {
+ buf[n] = 'b';
+ n += 1;
+ }
+ if (style.flags.faint) {
+ buf[n] = 'f';
+ n += 1;
+ }
+ if (style.flags.italic) {
+ buf[n] = 'i';
+ n += 1;
+ }
+ if (style.flags.blink) {
+ buf[n] = 'k';
+ n += 1;
+ }
+ if (style.flags.inverse) {
+ buf[n] = 'r';
+ n += 1;
+ }
+ if (style.flags.invisible) {
+ buf[n] = 'v';
+ n += 1;
+ }
+ if (style.flags.strikethrough) {
+ buf[n] = 'x';
+ n += 1;
+ }
+ const ul: []const u8 = switch (style.flags.underline) {
+ .none => "",
+ .single => "u",
+ .double => "U",
+ .curly => "~",
+ .dotted => ".",
+ .dashed => "-",
+ };
+ @memcpy(buf[n..][0..ul.len], ul);
+ n += ul.len;
+ return buf[0..n];
+}
+
+fn colorStr(color: ghostty_vt.Style.Color, buf: *[16]u8) []const u8 {
+ return switch (color) {
+ .none => "d",
+ .palette => |idx| std.fmt.bufPrint(buf, "p{d}", .{idx}) catch "?",
+ .rgb => |rgb| std.fmt.bufPrint(buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ rgb.r, rgb.g, rgb.b }) catch "?",
+ };
+}
+
+fn keyBytes(name: []const u8) ?[]const u8 {
+ const map = .{
+ .{ "enter", "\r" }, .{ "esc", "\x1b" }, .{ "tab", "\t" },
+ .{ "bs", "\x7f" }, .{ "space", " " }, .{ "up", "\x1b[A" },
+ .{ "down", "\x1b[B" }, .{ "right", "\x1b[C" }, .{ "left", "\x1b[D" },
+ .{ "home", "\x1b[H" }, .{ "end", "\x1b[F" }, .{ "pgup", "\x1b[5~" },
+ .{ "pgdn", "\x1b[6~" },
+ };
+ inline for (map) |e| if (std.mem.eql(u8, name, e[0])) return e[1];
+ const S = struct {
+ var buf: [2]u8 = undefined;
+ };
+ if (name.len == 1) {
+ S.buf[0] = name[0];
+ return S.buf[0..1];
+ }
+ if (name.len == 3 and name[1] == '-') {
+ if (name[0] == 'c' and name[2] >= 'a' and name[2] <= 'z') {
+ S.buf[0] = name[2] - 'a' + 1;
+ return S.buf[0..1];
+ }
+ if (name[0] == 'a') {
+ S.buf = .{ 0x1b, name[2] };
+ return S.buf[0..2];
+ }
+ }
+ return null;
+}
+
+fn buttonCode(name: []const u8) ?u16 {
+ if (std.mem.eql(u8, name, "left")) return 0;
+ if (std.mem.eql(u8, name, "middle")) return 1;
+ if (std.mem.eql(u8, name, "right")) return 2;
+ return null;
+}
+
+fn unescape(arena: std.mem.Allocator, s: []const u8) ![]const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ var i: usize = 0;
+ while (i < s.len) : (i += 1) {
+ if (s[i] != '\\' or i + 1 >= s.len) {
+ try buf.append(arena, s[i]);
+ continue;
+ }
+ i += 1;
+ switch (s[i]) {
+ 'e' => try buf.append(arena, 0x1b),
+ 'r' => try buf.append(arena, '\r'),
+ 'n' => try buf.append(arena, '\n'),
+ 't' => try buf.append(arena, '\t'),
+ '\\' => try buf.append(arena, '\\'),
+ 'x' => {
+ if (i + 2 >= s.len) return error.BadEscape;
+ try buf.append(arena, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
+ i += 2;
+ },
+ else => return error.BadEscape,
+ }
+ }
+ return buf.items;
+}
+
+fn absPath(arena: std.mem.Allocator, cwd: []const u8, p: []const u8) ![]const u8 {
+ if (p.len > 0 and p[0] == '/') return try arena.dupe(u8, p);
+ return try std.fmt.allocPrint(arena, "{s}/{s}", .{ cwd, p });
+}
+
+fn listSnaps(arena: std.mem.Allocator, dir_path: []const u8, scripts: *std.ArrayList([]const u8)) !void {
+ const dir_z = try arena.dupeZ(u8, dir_path);
+ const rc = linux.open(dir_z, .{ .ACCMODE = .RDONLY, .DIRECTORY = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) fatal("no snapshots/ dir at {s}", .{dir_path});
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: [8192]u8 align(@alignOf(linux.dirent64)) = undefined;
+ while (true) {
+ const r = linux.getdents64(fd, &buf, buf.len);
+ const n: isize = @bitCast(r);
+ if (n <= 0) break;
+ var off: usize = 0;
+ while (off < @as(usize, @intCast(n))) {
+ const d: *align(1) linux.dirent64 = @ptrCast(&buf[off]);
+ const name = std.mem.span(@as([*:0]const u8, @ptrCast(&d.name)));
+ if (std.mem.endsWith(u8, name, ".snap"))
+ try scripts.append(arena, try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }));
+ off += d.reclen;
+ }
+ }
+}
+
+fn readFileAlloc(arena: std.mem.Allocator, path: []const u8) ![]u8 {
+ const path_z = try arena.dupeZ(u8, path);
+ const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const r = linux.read(fd, &chunk, chunk.len);
+ const n: isize = @bitCast(r);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(arena, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn mkdir(arena: std.mem.Allocator, path: []const u8, exist_ok: bool) !void {
+ const path_z = try arena.dupeZ(u8, path);
+ const n: isize = @bitCast(linux.mkdir(path_z, 0o755));
+ if (n < 0) {
+ if (exist_ok and n == -@as(isize, @intFromEnum(linux.E.EXIST))) return;
+ return error.MkdirFailed;
+ }
+}
+
+fn chdirRc(path_z: [:0]const u8) isize {
+ return @bitCast(linux.chdir(path_z.ptr));
+}
+
+fn rmrf(path: []const u8) void {
+ var path_buf: [4096:0]u8 = undefined;
+ if (path.len >= path_buf.len) return;
+ @memcpy(path_buf[0..path.len], path);
+ path_buf[path.len] = 0;
+ const pid: isize = @bitCast(linux.fork());
+ if (pid == 0) {
+ const argv: [4:null]?[*:0]const u8 = .{ "rm", "-rf", @as([*:0]const u8, @ptrCast(&path_buf)), null };
+ _ = execvp("rm", &argv);
+ linux.exit_group(127);
+ }
+ if (pid < 0) return;
+ var status: u32 = 0;
+ _ = linux.wait4(@intCast(pid), &status, 0, null);
+}
+
+fn printFirstDiff(golden: []const u8, actual: []const u8) void {
+ var gi = std.mem.splitScalar(u8, golden, '\n');
+ var ai = std.mem.splitScalar(u8, actual, '\n');
+ var n: usize = 1;
+ while (true) : (n += 1) {
+ const g = gi.next();
+ const a = ai.next();
+ if (g == null and a == null) return;
+ if (g != null and a != null and std.mem.eql(u8, g.?, a.?)) continue;
+ std.debug.print(" first diff at golden line {d}:\n -{s}\n +{s}\n", .{ n, g orelse "<eof>", a orelse "<eof>" });
+ return;
+ }
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print(fmt ++ "\n", args);
+ std.process.exit(1);
+}
diff --git a/test/snapshots/boot.golden b/test/snapshots/boot.golden
new file mode 100644
index 00000000..85bac8dd
--- /dev/null
+++ b/test/snapshots/boot.golden
@@ -0,0 +1,62 @@
+== snap boot grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/boot/cwd Del nm /tmp/pardes-snap/boot/cwd Del
+|
+| hello.txt hello.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/boot/cwd Del
+|
+| hello.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style boot
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/boot.snap b/test/snapshots/boot.snap
new file mode 100644
index 00000000..33b12be0
--- /dev/null
+++ b/test/snapshots/boot.snap
@@ -0,0 +1,8 @@
+# default startup: 2 columns, 3 shells, each greets with ls; prompts hidden
+file hello.txt marker-content
+start 30 100
+wait 8000 Kill Newcol
+wait 8000 hello.txt
+stable 700 20000
+snap boot
+snapstyle boot
diff --git a/test/snapshots/dump.actual b/test/snapshots/dump.actual
new file mode 100644
index 00000000..cf39f324
--- /dev/null
+++ b/test/snapshots/dump.actual
@@ -0,0 +1,63 @@
+== file dump (3004 bytes)
+.{
+ .magic = "pardes-dump",
+ .version = 1,
+ .screen = .{ .cols = 100, .rows = 30 },
+ .active = 0,
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = .{ .{ .weight = 1, .panes = .{ 0, 1 } }, .{ .weight = 1, .panes = .{2} } },
+ .panes = .{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n typed-marker\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 14,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 13,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 28,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = "G10xMzM7QwcbXTEzMztEBxtdMTMzO0MHG10wO2dvYmxpbkBwYXJkZXM6L3RtcC9wYXJkZXMtc25hcC9kdW1wL2N3ZAcbWz8yMDA0aBtdMTMzO0E7Y2w9bGluZQckIBtdMTMzO0IHDRtbSw0bXTEzMztBO2NsPWxpbmUHJCAbXTEzMztCB2xzDQobWz8yMDA0bA0bXTEzMztDB2QudHh0DQobXTEzMztDBxtdMTMzO0QHG10xMzM7QwcbXTA7Z29ibGluQHBhcmRlczovdG1wL3BhcmRlcy1zbmFwL2R1bXAvY3dkBxtbPzIwMDRoG10xMzM7QTtjbD1saW5lByQgG10xMzM7Qgc=",
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ },
+}== end file
diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden
new file mode 100644
index 00000000..c18003d5
--- /dev/null
+++ b/test/snapshots/dump.golden
@@ -0,0 +1,64 @@
+== file dump
+.{
+ .magic = "pardes-dump",
+ .version = 1,
+ .screen = .{ .cols = 100, .rows = 30 },
+ .active = 0,
+ .topbar = "Kill Newcol Tutor Debug Colors NextColor Dump",
+ .theme = "dark",
+ .columns = .{ .{ .weight = 1, .panes = .{ 0, 1 } }, .{ .weight = 1, .panes = .{2} } },
+ .panes = .{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n typed-marker\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 14,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 13,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp/pardes-snap/dump/cwd Del",
+ .body = "\nd.txt\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+ .scroll = 0,
+ .cols = 48,
+ .rows = 28,
+ .vweight = 1,
+ .terminal = .{
+ .cwd = "/tmp/pardes-snap/dump/cwd",
+ .stream = "\nd.txt\n",
+ .stream_b64 = <elided>
+ .cursor = .{ .col = 2, .row = 2 },
+ },
+ .file = null,
+ .image = null,
+ },
+ },
+}
+== end file
diff --git a/test/snapshots/dump.snap b/test/snapshots/dump.snap
new file mode 100644
index 00000000..85664c2f
--- /dev/null
+++ b/test/snapshots/dump.snap
@@ -0,0 +1,18 @@
+# the Dump builtin serializes the session to /tmp/pardes-dump.zon; the ZON must
+# be byte-identical between old and new (golden generated from the OLD binary)
+file d.txt x
+start 30 100
+wait 8000 d.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text typed-marker
+key esc
+settle 700
+stable 400 5000
+press middle 43 1
+release middle 43 1
+settle 800
+snapfile dump /tmp/pardes-dump.zon
diff --git a/test/snapshots/edit.actual b/test/snapshots/edit.actual
new file mode 100644
index 00000000..e45687a2
--- /dev/null
+++ b/test/snapshots/edit.actual
@@ -0,0 +1,62 @@
+== snap inserted grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/edit/cwd Del nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style inserted
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/edit.golden b/test/snapshots/edit.golden
new file mode 100644
index 00000000..846bfa30
--- /dev/null
+++ b/test/snapshots/edit.golden
@@ -0,0 +1,62 @@
+== snap inserted grid=100x30 cursor=4,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/edit/cwd Del nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+| hello-insert
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/edit/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style inserted
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/edit.snap b/test/snapshots/edit.snap
new file mode 100644
index 00000000..660add70
--- /dev/null
+++ b/test/snapshots/edit.snap
@@ -0,0 +1,15 @@
+# insert-mode splice: click into a body row, type, escape back to normal
+file e.txt x
+start 30 100
+wait 8000 e.txt
+stable 700 20000
+press left 5 10
+release left 5 10
+settle 400
+key i
+text hello-insert
+key esc
+settle 700
+stable 500 8000
+snap inserted
+snapstyle inserted
diff --git a/test/snapshots/exec.golden b/test/snapshots/exec.golden
new file mode 100644
index 00000000..23b1305b
--- /dev/null
+++ b/test/snapshots/exec.golden
@@ -0,0 +1,124 @@
+== snap exec grid=100x30 cursor=4,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+| echo MID''OK
+| MIDOK
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap kept grid=100x30 cursor=6,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+| ZZARG
+| ./c.sh e.txt
+|
+|
+|
+| echo MID''OK
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap chord grid=100x30 cursor=6,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+| ZZARG
+| ./c.sh e.txt
+|
+|
+|
+| echo MID''OK
+| CHORDARG:ZZARG
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap deleted grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/exec/cwd Del nm /tmp/pardes-snap/exec/cwd Del
+|
+| e.txt e.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/exec.snap b/test/snapshots/exec.snap
new file mode 100644
index 00000000..dd8c24ee
--- /dev/null
+++ b/test/snapshots/exec.snap
@@ -0,0 +1,46 @@
+# middle-drag executes the selection in the pane's shell; a left press during
+# the middle drag captures the kept left selection as a trailing argument;
+# middle-click Del on the pane tag closes the pane
+file e.txt x
+start 30 100
+wait 8000 e.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text echo MID''OK
+key esc
+settle 700
+stable 400 5000
+press middle 5 8
+drag middle 16 8
+release middle 16 8
+wait 10000 MIDOK
+stable 700 10000
+snap exec
+# chord (canonical sequence, as in the prototype e2e): print an arg word and a
+# script path, left-drag-keep the arg, then middle-press on the path + left
+# press (captures) + middle release (runs with the arg appended)
+key c-b
+stable 600 8000
+text printf '#!/bin/sh\necho CHORDARG:"$1"\n' > c.sh; chmod +x c.sh; clear; printf 'ZZARG\n./c.sh\n'
+key enter
+wait 10000 ./c.sh
+stable 700 10000
+key c-b
+stable 600 8000
+press left 3 3
+drag left 7 3
+release left 7 3
+stable 400 5000
+snap kept
+send \x1b[<1;4;4M\x1b[<0;4;4M\x1b[<1;4;4m
+wait 10000 CHORDARG:ZZARG
+stable 700 10000
+snap chord
+# middle-click Del in this pane's tag (after cwd prefix) closes it
+press middle 32 2
+release middle 32 2
+stable 700 10000
+snap deleted
diff --git a/test/snapshots/fileedit.golden b/test/snapshots/fileedit.golden
new file mode 100644
index 00000000..c6fb40aa
--- /dev/null
+++ b/test/snapshots/fileedit.golden
@@ -0,0 +1,116 @@
+== snap edited grid=100x30 cursor=17,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 2 f.txt
+| 3 line 3
+| 4 line 4
+| 5 line 5
+| 6 line 6
+| 7 line 7
+| 8 line 8
+| 9 line 9
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== snap deleted grid=100x30 cursor=7,3
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 3 f.txt
+| 3 line 4
+| 4 line 5
+| 5 line 6
+| 6 line 7
+| 7 line 8
+| 8 line 9
+| 9 line 10
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== snap undone grid=100x30 cursor=7,3
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/fileedit/cwd/f.txt Save Del nm /tmp/pardes-snap/fileedit/cwd Del
+| 1 liEDIT''MEne 1
+| 2 line 2 f.txt
+| 3 line 3
+| 4 line 4
+| 5 line 5
+| 6 line 6
+| 7 line 7
+| 8 line 8
+| 9 line 9
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+| f.txt
+|
+|
+|
+| nm /tmp/pardes-snap/fileedit/cwd Del
+|
+| f.txt
+|
+|
+|
+|
+|
+|
+== file saved
+liEDIT''MEne 1
+line 2
+line 3
+line 4
+line 5
+line 6
+line 7
+line 8
+line 9
+line 10
+line 11
+line 12
+line 13
+line 14
+line 15
+line 16
+line 17
+line 18
+line 19
+line 20
+
+== end file
diff --git a/test/snapshots/fileedit.snap b/test/snapshots/fileedit.snap
new file mode 100644
index 00000000..7bafa358
--- /dev/null
+++ b/test/snapshots/fileedit.snap
@@ -0,0 +1,40 @@
+# file panes: modal editing on real content, undo, Save writes to disk
+lines f.txt 20
+start 30 100
+wait 8000 f.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text f.txt
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 line 3
+stable 700 15000
+# edit line 1: click it, insert, esc
+press left 10 3
+release left 10 3
+stable 400 5000
+key i
+text EDIT''ME
+key esc
+settle 700
+stable 400 5000
+snap edited
+# x-select line 2 and delete it
+key j g h
+stable 400 5000
+key x d
+stable 400 5000
+snap deleted
+key u
+stable 400 5000
+snap undone
+# Save via the tag: middle-click the Save word (after "nm <path> ")
+press middle 42 2
+release middle 42 2
+settle 800
+snapfile saved /tmp/pardes-snap/fileedit/cwd/f.txt
diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden
new file mode 100644
index 00000000..b6e75fbf
--- /dev/null
+++ b/test/snapshots/images.golden
@@ -0,0 +1,124 @@
+== snap img-open grid=100x30 cursor=4,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| img /tmp/pardes-snap/images/cwd/pic.ppm Petscii nm /tmp/pardes-snap/images/cwd Del
+| 🬝🬹
+| 🬝▀█ pic.ppm
+| 🬝▀█
+| 🬝▀█
+| 🬝▀█
+| █🬆█
+| █🬆█
+| █🬆█
+| 🬝🬹█
+| nm /tmp/pardes-snap/images/cwd Del
+|
+| pic.ppm
+|
+|
+|
+| pic.ppm
+|
+|
+|
+| nm /tmp/pardes-snap/images/cwd Del
+|
+| pic.ppm
+|
+|
+|
+|
+|
+|
+== style img-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style term-palette
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style no-ascii
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-16 d,d, 17-32 #ffffff,#ffffff, 33-33 #ffffff,#352879, 34-34 #352879,#ffffff, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-16 d,d, 17-30 #ffffff,#ffffff, 31-32 #ffffff,#352879, 33-33 #352879,#ffffff, 34-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-16 d,d, 17-28 #ffffff,#ffffff, 29-30 #ffffff,#352879, 31-31 #352879,#ffffff, 32-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-16 d,d, 17-26 #ffffff,#ffffff, 27-28 #ffffff,#352879, 29-29 #352879,#ffffff, 30-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-16 d,d, 17-24 #ffffff,#ffffff, 25-26 #ffffff,#352879, 27-27 #352879,#ffffff, 28-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-16 d,d, 17-22 #ffffff,#ffffff, 23-24 #ffffff,#352879, 25-25 #352879,#ffffff, 26-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-16 d,d, 17-20 #ffffff,#ffffff, 21-22 #ffffff,#352879, 23-23 #352879,#ffffff, 24-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-16 d,d, 17-18 #ffffff,#ffffff, 19-20 #ffffff,#352879, 21-21 #352879,#ffffff, 22-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-16 d,d, 17-17 #ffffff,#352879, 18-19 #352879,#ffffff, 20-34 #352879,#352879, 35-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/images.snap b/test/snapshots/images.snap
new file mode 100644
index 00000000..1b9bf715
--- /dev/null
+++ b/test/snapshots/images.snap
@@ -0,0 +1,29 @@
+# image panes: right-click a .ppm opens petscii glyph art (no kitty in the
+# harness); the live tag toggles palette (C64<->Term) and Ascii glyphs
+ppmdiag pic.ppm
+start 30 100
+wait 8000 pic.ppm
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text pic.ppm
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 img
+stable 700 20000
+snap img-open
+snapstyle img-open
+# toggle palette to terminal colors: middle-click the C64 word in the tag
+press middle 57 2
+release middle 57 2
+stable 700 15000
+snapstyle term-palette
+# toggle Ascii off
+press middle 62 2
+release middle 62 2
+stable 700 15000
+snapstyle no-ascii
diff --git a/test/snapshots/load.golden b/test/snapshots/load.golden
new file mode 100644
index 00000000..ade03fe3
--- /dev/null
+++ b/test/snapshots/load.golden
@@ -0,0 +1,62 @@
+== snap loaded grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/load/cwd Del nm /tmp/pardes-snap/load/cwd Del
+|
+| LOADMARK d.txt
+| 1
+| 2
+| 3
+| 4
+| 5
+| 6
+| 7
+| 8
+| 9
+| 10
+| 11
+| 12
+| nm /tmp/pardes-snap/load/cwd Del
+|
+| d.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style loaded
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#383c54, 52-83 #969696,#2c2a3e, 84-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-33 #969696,#2c2a3e, 34-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/load.snap b/test/snapshots/load.snap
new file mode 100644
index 00000000..0a9c9e55
--- /dev/null
+++ b/test/snapshots/load.snap
@@ -0,0 +1,27 @@
+# pardes -l restores another instance's dump (NEW behavior — the old app had a
+# separate replay viewer; this golden is generated from the NEW binary as its
+# own regression baseline). Shell output survives the replay; typed insert runs
+# are an overlay and do not (same semantics as the old replay viewer).
+file d.txt x
+start 30 100
+wait 8000 d.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text echo LOAD''MARK; seq 1 40
+key enter
+wait 8000 LOADMARK
+stable 700 10000
+key c-b
+stable 600 8000
+wheel up 10 8
+wheel up 10 8
+stable 500 8000
+press middle 43 1
+release middle 43 1
+settle 800
+start 30 100 -l /tmp/pardes-dump.zon
+wait 8000 LOADMARK
+stable 700 20000
+snap loaded
+snapstyle loaded
diff --git a/test/snapshots/look-dir.actual b/test/snapshots/look-dir.actual
new file mode 100644
index 00000000..523560ba
--- /dev/null
+++ b/test/snapshots/look-dir.actual
@@ -0,0 +1,31 @@
+== snap dir-open grid=100x30 cursor=4,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-dir/cwd Del nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt sub top.txt
+|
+| nm /tmp/pardes-snap/look-dir/cwd/sub Del
+| ls
+|
+| inner.txt
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/look-dir.golden b/test/snapshots/look-dir.golden
new file mode 100644
index 00000000..c4337878
--- /dev/null
+++ b/test/snapshots/look-dir.golden
@@ -0,0 +1,31 @@
+== snap dir-open grid=100x30 cursor=4,8
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-dir/cwd Del nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt sub top.txt
+|
+| nm /tmp/pardes-snap/look-dir/cwd/sub Del
+|
+| inner.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/look-dir/cwd Del
+|
+| sub top.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap
new file mode 100644
index 00000000..04b43f6e
--- /dev/null
+++ b/test/snapshots/look-dir.snap
@@ -0,0 +1,20 @@
+# right-click look on a typed dir name opens a shell there (split below) + ls
+dirmk sub
+file sub/inner.txt inner-marker
+file top.txt x
+start 30 100
+wait 8000 top.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text sub
+key esc
+settle 700
+stable 400 5000
+press right 6 8
+release right 6 8
+wait 10000 inner.txt
+stable 700 20000
+snap dir-open
diff --git a/test/snapshots/look-file.golden b/test/snapshots/look-file.golden
new file mode 100644
index 00000000..4f6d4a71
--- /dev/null
+++ b/test/snapshots/look-file.golden
@@ -0,0 +1,62 @@
+== snap file-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/look-file/cwd/big.txt Save D nm /tmp/pardes-snap/look-file/cwd Del
+| 40 line 40
+| 41 line 41 big.txt
+| 42 line 42
+| 43 line 43
+| 44 line 44
+| 45 line 45
+| 46 line 46
+| 47 line 47
+| 48 line 48
+| nm /tmp/pardes-snap/look-file/cwd Del
+|
+| big.txt
+|
+|
+|
+| big.txt:40
+|
+|
+|
+| nm /tmp/pardes-snap/look-file/cwd Del
+|
+| big.txt
+|
+|
+|
+|
+|
+|
+== style file-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-88 #969696,#2c2a3e, 89-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/look-file.snap b/test/snapshots/look-file.snap
new file mode 100644
index 00000000..83010317
--- /dev/null
+++ b/test/snapshots/look-file.snap
@@ -0,0 +1,19 @@
+# right-click look on a typed path:NN opens a file pane scrolled to the line;
+# Enter chord on the same path focuses the existing pane (dedup)
+lines big.txt 60
+start 30 100
+wait 8000 big.txt
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text big.txt:40
+key esc
+settle 700
+stable 400 5000
+press right 7 8
+release right 7 8
+stable 700 15000
+snap file-open
+snapstyle file-open
diff --git a/test/snapshots/modal.golden b/test/snapshots/modal.golden
new file mode 100644
index 00000000..b4922f12
--- /dev/null
+++ b/test/snapshots/modal.golden
@@ -0,0 +1,372 @@
+== snap top grid=100x30 cursor=2,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap moved grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap xsel grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style xsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap xsel2 grid=100x30 cursor=2,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap yanked grid=100x30 cursor=2,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap vsel grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style vsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 #d8c8a8,#4a3a1e, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-3 #d8c8a8,#4a3a1e, 4-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap typed grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap undone grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| four five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap redone grid=100x30 cursor=3,7
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap bottom grid=100x30 cursor=2,9
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/modal/cwd Del nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt words.txt
+|
+|
+| one two three
+| fTYPEDour five six
+| seven eight nine
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/modal/cwd Del
+|
+| words.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/modal.snap b/test/snapshots/modal.snap
new file mode 100644
index 00000000..adbfd533
--- /dev/null
+++ b/test/snapshots/modal.snap
@@ -0,0 +1,53 @@
+# helix-modal normal mode: motions, x/v selections, y/d, insert, undo/redo
+file words.txt alpha beta
+start 30 100
+wait 8000 words.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text printf 'one two three\nfour five six\nseven eight nine\n'
+key enter
+wait 8000 seven
+stable 700 10000
+key c-b
+stable 600 8000
+press left 5 6
+release left 5 6
+stable 400 5000
+key g g
+stable 400 5000
+snap top
+key j j w
+stable 400 5000
+snap moved
+key x
+stable 400 5000
+snap xsel
+snapstyle xsel
+key x
+stable 400 5000
+snap xsel2
+key y
+stable 400 5000
+snap yanked
+key v j l
+stable 400 5000
+snap vsel
+snapstyle vsel
+key esc
+settle 700
+key i
+text TYPED
+key esc
+settle 700
+stable 400 5000
+snap typed
+key u
+stable 400 5000
+snap undone
+key U
+stable 400 5000
+snap redone
+key G
+stable 400 5000
+snap bottom
diff --git a/test/snapshots/scroll.golden b/test/snapshots/scroll.golden
new file mode 100644
index 00000000..899c7dec
--- /dev/null
+++ b/test/snapshots/scroll.golden
@@ -0,0 +1,186 @@
+== snap bottom grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 188
+| 189 s.txt
+| 190
+| 191
+| 192
+| 193
+| 194
+| 195
+| 196
+| 197
+| 198
+| 199
+| 200
+|
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap wheel3 grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 185
+| 186 s.txt
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| 194
+| 195
+| 196
+| 197
+| 198
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap gutter-up grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 178
+| 179 s.txt
+| 180
+| 181
+| 182
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap gutter-down grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 180
+| 181 s.txt
+| 182
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap wheel-down grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/scroll/cwd Del nm /tmp/pardes-snap/scroll/cwd Del
+| 181
+| 182 s.txt
+| 183
+| 184
+| 185
+| 186
+| 187
+| 188
+| 189
+| 190
+| 191
+| 192
+| 193
+| 194
+| nm /tmp/pardes-snap/scroll/cwd Del
+|
+| s.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style wheel-down
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#2a2a2a, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/scroll.snap b/test/snapshots/scroll.snap
new file mode 100644
index 00000000..8e55224d
--- /dev/null
+++ b/test/snapshots/scroll.snap
@@ -0,0 +1,31 @@
+# scrolling: wheel + scrollbar gutter clicks over real scrollback
+file s.txt x
+start 30 100
+wait 8000 s.txt
+stable 700 20000
+key c-b
+stable 600 8000
+text seq 1 200
+key enter
+wait 8000 199
+stable 700 15000
+key c-b
+stable 600 8000
+snap bottom
+wheel up 10 8
+wheel up 10 8
+wheel up 10 8
+stable 500 8000
+snap wheel3
+press left 1 10
+release left 1 10
+stable 500 8000
+snap gutter-up
+press right 1 5
+release right 1 5
+stable 500 8000
+snap gutter-down
+wheel down 10 8
+stable 500 8000
+snap wheel-down
+snapstyle wheel-down
diff --git a/test/snapshots/syntax.actual b/test/snapshots/syntax.actual
new file mode 100644
index 00000000..d8c537b7
--- /dev/null
+++ b/test/snapshots/syntax.actual
@@ -0,0 +1,124 @@
+== snap zig-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/syntax/cwd/demo.zig Save Del nm /tmp/pardes-snap/syntax/cwd Del
+| 1 const std = @import("std"); // greet
+| 2 pub fn main() void { demo.zig
+| 3 var x: u32 = 42;
+| 4 _ = x;
+| 5 }
+|
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+| demo.zig
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+|
+|
+|
+== style zig-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-off
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/syntax.golden b/test/snapshots/syntax.golden
new file mode 100644
index 00000000..310d6d64
--- /dev/null
+++ b/test/snapshots/syntax.golden
@@ -0,0 +1,124 @@
+== snap zig-open grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/syntax/cwd/demo.zig Save Del nm /tmp/pardes-snap/syntax/cwd Del
+| 1 const std = @import("std"); // greet
+| 2 pub fn main() void { demo.zig
+| 3 var x: u32 = 42;
+| 4 _ = x;
+| 5 }
+|
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+| demo.zig
+|
+|
+|
+| nm /tmp/pardes-snap/syntax/cwd Del
+|
+| demo.zig
+|
+|
+|
+|
+|
+|
+== style zig-open
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-off
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== style colors-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-49 #969696,#2c2a3e, 50-51 d,#383c54, 52-85 #969696,#2c2a3e, 86-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-18 d,d, 19-25 #cc70d8,d,b 26-26 d,d, 27-31 #8ab87a,d, 32-34 d,d, 35-42 #f0f0f0,d,b 43-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-10 d,d, 11-12 #cc70d8,d,b 13-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-23 d,d, 24-25 #d0c070,d, 26-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-6 #5a5a5a,d, 7-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#383c54, 2-35 #969696,#2c2a3e, 36-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/syntax.snap b/test/snapshots/syntax.snap
new file mode 100644
index 00000000..690e5d7d
--- /dev/null
+++ b/test/snapshots/syntax.snap
@@ -0,0 +1,27 @@
+# tree-sitter: open a .zig file, keyword/string/number/comment colors on;
+# Colors off clears them; edit re-highlights
+file demo.zig const std = @import("std"); // greet\npub fn main() void {\n var x: u32 = 42;\n _ = x;\n}
+start 30 100
+wait 8000 demo.zig
+stable 700 20000
+press left 5 8
+release left 5 8
+stable 400 5000
+key i
+text demo.zig
+key esc
+settle 700
+press right 6 8
+release right 6 8
+wait 10000 @import
+stable 700 15000
+snap zig-open
+snapstyle zig-open
+press middle 25 1
+release middle 25 1
+stable 500 10000
+snapstyle colors-off
+press middle 25 1
+release middle 25 1
+stable 500 10000
+snapstyle colors-on
diff --git a/test/snapshots/tag.golden b/test/snapshots/tag.golden
new file mode 100644
index 00000000..9275777b
--- /dev/null
+++ b/test/snapshots/tag.golden
@@ -0,0 +1,217 @@
+== snap tag-focus grid=100x30 cursor=33,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-typed grid=100x30 cursor=46,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-exec grid=100x30 cursor=45,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-vsel grid=100x30 cursor=31,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwd Del echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style tag-vsel
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-28 #969696,#2c2a3e, 29-31 #d8c8a8,#4a3a1e, 32-45 #969696,#2c2a3e, 46-49 d,#2c2a3e, 50-51 d,#383c54, 52-82 #969696,#2c2a3e, 83-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-32 #969696,#2c2a3e, 33-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+== snap tag-del grid=100x30 cursor=29,1
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwdl echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tag-exit grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tag/cwdl echo TAG''OK nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt t.txt
+|
+| TAGOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tag/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tag.snap b/test/snapshots/tag.snap
new file mode 100644
index 00000000..f77808cb
--- /dev/null
+++ b/test/snapshots/tag.snap
@@ -0,0 +1,41 @@
+# tag editor: click the tag row, edit the tail modally, execute a typed word
+file t.txt x
+start 30 100
+wait 8000 t.txt
+stable 700 20000
+# click into the tag tail (after "Del", col past prefix) of pane 0
+press left 40 2
+release left 40 2
+stable 400 5000
+snap tag-focus
+# append a word: A enters insert at end of tail
+key A
+send \x20echo\x20TAG''OK
+key esc
+settle 700
+stable 400 5000
+snap tag-typed
+# v-select "echo TAG''OK" (col 4 .. line end) and execute it with Tab
+key g h
+key l l l l
+key v
+key g l
+stable 400 5000
+key tab
+wait 10000 TAGOK
+stable 700 10000
+snap tag-exec
+# v-select a few chars and delete them
+key g h
+stable 400 5000
+key v l l
+stable 400 5000
+snap tag-vsel
+snapstyle tag-vsel
+key d
+stable 400 5000
+snap tag-del
+# esc: first collapses selection state, then leaves tag editing
+key esc
+settle 700
+snap tag-exit
diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden
new file mode 100644
index 00000000..b90e0011
--- /dev/null
+++ b/test/snapshots/theme.golden
@@ -0,0 +1,186 @@
+== snap acme grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style acme
+|0: 0-44 #000000,#eaffff, 45-99 d,#eaffff,
+|1: 0-1 d,#6e4fd0, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#383c54, 52-84 #000000,#eaffff, 85-99 d,#eaffff,
+|2: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|3: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|4: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|5: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|6: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|7: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|8: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|9: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|10: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|11: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|12: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|13: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|14: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|15: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|16: 0-1 d,#383c54, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|17: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|18: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|19: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|20: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|21: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|22: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|23: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|24: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|25: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|26: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|27: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|28: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+|29: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea,
+== snap colors-off grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style colors-off
+|0: 0-44 #000000,#eaffff, 45-99 d,#eaffff,
+|1: 0-1 d,#6e4fd0, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 d,#383c54, 52-84 #000000,#eaffff, 85-99 d,#eaffff,
+|2: 0-99 d,#ffffea,
+|3: 0-1 d,#ffffea, 2-6 #000000,#ffffea, 7-51 d,#ffffea, 52-56 #000000,#ffffea, 57-99 d,#ffffea,
+|4: 0-99 d,#ffffea,
+|5: 0-99 d,#ffffea,
+|6: 0-99 d,#ffffea,
+|7: 0-99 d,#ffffea,
+|8: 0-99 d,#ffffea,
+|9: 0-99 d,#ffffea,
+|10: 0-99 d,#ffffea,
+|11: 0-99 d,#ffffea,
+|12: 0-99 d,#ffffea,
+|13: 0-99 d,#ffffea,
+|14: 0-99 d,#ffffea,
+|15: 0-99 d,#ffffea,
+|16: 0-1 d,#383c54, 2-34 #000000,#eaffff, 35-49 d,#eaffff, 50-99 d,#ffffea,
+|17: 0-99 d,#ffffea,
+|18: 0-1 d,#ffffea, 2-6 #000000,#ffffea, 7-99 d,#ffffea,
+|19: 0-99 d,#ffffea,
+|20: 0-99 d,#ffffea,
+|21: 0-99 d,#ffffea,
+|22: 0-99 d,#ffffea,
+|23: 0-99 d,#ffffea,
+|24: 0-99 d,#ffffea,
+|25: 0-99 d,#ffffea,
+|26: 0-99 d,#ffffea,
+|27: 0-99 d,#ffffea,
+|28: 0-99 d,#ffffea,
+|29: 0-99 d,#ffffea,
+== snap dark-again grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/theme/cwd Del nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/theme/cwd Del
+|
+| t.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style dark-again
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#383c54, 52-84 #969696,#2c2a3e, 85-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|3: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|4: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|5: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|6: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|7: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|8: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|9: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|10: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|11: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|12: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|13: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|14: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|15: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|16: 0-1 d,#383c54, 2-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 d,#525252, 52-99 d,d,
+|17: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|18: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|19: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|20: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|21: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|22: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|23: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|24: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|25: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|26: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|27: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|28: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
+|29: 0-1 d,#525252, 2-49 d,d, 50-51 d,#525252, 52-99 d,d,
diff --git a/test/snapshots/theme.snap b/test/snapshots/theme.snap
new file mode 100644
index 00000000..90ce5f85
--- /dev/null
+++ b/test/snapshots/theme.snap
@@ -0,0 +1,24 @@
+# topbar builtins: NextColor cycles to acme-light and back; Colors toggles
+# the recolor passes off and on
+file t.txt x
+start 30 100
+wait 8000 t.txt
+stable 700 20000
+press middle 32 1
+release middle 32 1
+stable 700 10000
+snap acme
+snapstyle acme
+press middle 25 1
+release middle 25 1
+stable 700 10000
+snap colors-off
+snapstyle colors-off
+press middle 25 1
+release middle 25 1
+stable 700 10000
+press middle 32 1
+release middle 32 1
+stable 700 10000
+snap dark-again
+snapstyle dark-again
diff --git a/test/snapshots/tty.golden b/test/snapshots/tty.golden
new file mode 100644
index 00000000..7e6bebc5
--- /dev/null
+++ b/test/snapshots/tty.golden
@@ -0,0 +1,93 @@
+== snap tty-on grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+| $ ls
+| m.txt m.txt
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tty-echo grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+| $ ls
+| m.txt m.txt
+| $ echo SNAP''OK
+| SNAPOK
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap tty-off grid=100x30 cursor=4,6
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/tty/cwd Del nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt m.txt
+|
+| SNAPOK
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tty/cwd Del
+|
+| m.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tty.snap b/test/snapshots/tty.snap
new file mode 100644
index 00000000..6d6e0f3e
--- /dev/null
+++ b/test/snapshots/tty.snap
@@ -0,0 +1,16 @@
+# tty mode round-trip: Ctrl-b in, run a command, Ctrl-b out
+file m.txt marker
+start 30 100
+wait 8000 m.txt
+stable 700 20000
+key c-b
+stable 600 8000
+snap tty-on
+text echo SNAP''OK
+key enter
+wait 8000 SNAPOK
+stable 700 10000
+snap tty-echo
+key c-b
+stable 600 8000
+snap tty-off
diff --git a/test/snapshots/ttyonly.golden b/test/snapshots/ttyonly.golden
new file mode 100644
index 00000000..20ddf792
--- /dev/null
+++ b/test/snapshots/ttyonly.golden
@@ -0,0 +1,62 @@
+== snap ttyonly grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| sy /tmp/pardes-snap/ttyonly/cwd Del
+| $ echo TTY''ONLY
+| TTYONLY
+| $
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== style ttyonly
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#6e4fd0, 2-36 #969696,#2c2a3e, 37-99 d,#2c2a3e,
+|2: 0-1 d,#525252, 2-99 d,d,
+|3: 0-1 d,#525252, 2-99 d,d,
+|4: 0-1 d,#525252, 2-99 d,d,
+|5: 0-1 d,#525252, 2-99 d,d,
+|6: 0-1 d,#525252, 2-99 d,d,
+|7: 0-1 d,#525252, 2-99 d,d,
+|8: 0-1 d,#525252, 2-99 d,d,
+|9: 0-1 d,#525252, 2-99 d,d,
+|10: 0-1 d,#525252, 2-99 d,d,
+|11: 0-1 d,#525252, 2-99 d,d,
+|12: 0-1 d,#525252, 2-99 d,d,
+|13: 0-1 d,#525252, 2-99 d,d,
+|14: 0-1 d,#525252, 2-99 d,d,
+|15: 0-1 d,#525252, 2-99 d,d,
+|16: 0-1 d,#525252, 2-99 d,d,
+|17: 0-1 d,#525252, 2-99 d,d,
+|18: 0-1 d,#525252, 2-99 d,d,
+|19: 0-1 d,#525252, 2-99 d,d,
+|20: 0-1 d,#525252, 2-99 d,d,
+|21: 0-1 d,#525252, 2-99 d,d,
+|22: 0-1 d,#525252, 2-99 d,d,
+|23: 0-1 d,#525252, 2-99 d,d,
+|24: 0-1 d,#525252, 2-99 d,d,
+|25: 0-1 d,#525252, 2-99 d,d,
+|26: 0-1 d,#525252, 2-99 d,d,
+|27: 0-1 d,#525252, 2-99 d,d,
+|28: 0-1 d,#525252, 2-99 d,d,
+|29: 0-1 d,#525252, 2-99 d,d,
diff --git a/test/snapshots/ttyonly.snap b/test/snapshots/ttyonly.snap
new file mode 100644
index 00000000..c3d44063
--- /dev/null
+++ b/test/snapshots/ttyonly.snap
@@ -0,0 +1,11 @@
+# --tty: a single fullscreen shell already in raw tty mode, prompts visible
+file t.txt x
+start 30 100 --tty
+wait 8000 $
+stable 700 20000
+text echo TTY''ONLY
+key enter
+wait 8000 TTYONLY
+stable 700 10000
+snap ttyonly
+snapstyle ttyonly
diff --git a/test/snapshots/tutor.golden b/test/snapshots/tutor.golden
new file mode 100644
index 00000000..55d66c9b
--- /dev/null
+++ b/test/snapshots/tutor.golden
@@ -0,0 +1,31 @@
+== snap tutor grid=100x30 cursor=7,2
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /Tutor Save Del nm /tmp/pardes-snap/tutor/cwd Del
+| 1
+| 2 PARDES TUTOR
+| 3 tmux + vi + acme, from
+| 4
+| 5 The model is acme's: EVERYTHING is
+| 6 the same way. Modal editing (helix-
+| 7 Press j until you reach the introdu
+| 8
+| 9
+| nm /tmp/pardes-snap/tutor/cwd Del
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/tutor/cwd Del
+|
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/tutor.snap b/test/snapshots/tutor.snap
new file mode 100644
index 00000000..6322c92a
--- /dev/null
+++ b/test/snapshots/tutor.snap
@@ -0,0 +1,8 @@
+# topbar middle-click Tutor opens the embedded tutor as a file pane top-left
+start 30 100
+wait 8000 Kill Newcol
+stable 700 20000
+press middle 13 1
+release middle 13 1
+stable 700 15000
+snap tutor
diff --git a/test/snapshots/windowops.golden b/test/snapshots/windowops.golden
new file mode 100644
index 00000000..061ceca9
--- /dev/null
+++ b/test/snapshots/windowops.golden
@@ -0,0 +1,372 @@
+== snap hover-v grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del ╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del ╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap hover-h grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap border-drag grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap border-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del╎ nm /tmp/pardes-snap/windowops/cwd Del
+| ╎
+| w.txt ╎ w.txt
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| nm /tmp/pardes-snap/windowops/cwd Del╎
+| ╎
+| w.txt ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+| ╎
+== snap hborder-done grid=100x30 cursor=4,4
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap move-preview grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap moved grid=100x30 cursor=4,15
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+== snap altn grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== snap focus-left grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== snap debug-on grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowop╭────────────────────────────╮
+| │pardes resizes=1 │
+| w.txt w.txt │terms 4 cols 2 │
+| │active #3 38x9 │
+| │scroll 0/9 │
+| │pinch 1.0000 │
+| │tscroll 0.0000 │
+| nm /tmp/pardes-snap/windowops/cwd Del ╰────────────────────────────╯
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+== style debug-on
+|0: 0-44 #969696,#2c2a3e, 45-99 d,#2c2a3e,
+|1: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#383c54, 42-69 #969696,#2c2a3e, 70-99 d,d,
+|2: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-87 #969696,d, 88-98 d,#2a2a2a, 99-99 d,d,
+|3: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|4: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|5: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-80 #969696,d, 81-98 d,#2a2a2a, 99-99 d,d,
+|6: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-83 #969696,d, 84-98 d,#2a2a2a, 99-99 d,d,
+|7: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-70 d,d, 71-85 #969696,d, 86-98 d,#2a2a2a, 99-99 d,d,
+|8: 0-1 d,#383c54, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#525252, 42-99 d,d,
+|9: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|10: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|11: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|12: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|13: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|14: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|15: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|16: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|17: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|18: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|19: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|20: 0-1 d,#6e4fd0, 2-38 #969696,#2c2a3e, 39-39 d,#2c2a3e, 40-41 d,#525252, 42-99 d,d,
+|21: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|22: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|23: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|24: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|25: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|26: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|27: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|28: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+|29: 0-1 d,#525252, 2-39 d,d, 40-41 d,#525252, 42-99 d,d,
+== snap debug-off grid=100x30 cursor=4,23
+|Kill Newcol Tutor Debug Colors NextColor Dump
+| nm /tmp/pardes-snap/windowops/cwd Del nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt w.txt
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| nm /tmp/pardes-snap/windowops/cwd Del
+|
+| w.txt
+|
+|
+|
+|
+|
+|
+|
diff --git a/test/snapshots/windowops.snap b/test/snapshots/windowops.snap
new file mode 100644
index 00000000..7e6cbee4
--- /dev/null
+++ b/test/snapshots/windowops.snap
@@ -0,0 +1,53 @@
+# hover resize hints, border drags, move drag, Alt-n, Ctrl-w focus, Debug box
+file w.txt x
+start 30 100
+wait 8000 w.txt
+stable 700 20000
+# hover over the left column's last col: vertical dash hint
+motion 50 10
+stable 400 5000
+snap hover-v
+# hover over the top-left pane's last row: horizontal dash hint
+motion 20 16
+stable 400 5000
+snap hover-h
+# drag the vertical border 10 cols left
+press left 50 10
+drag left 40 10
+stable 400 5000
+snap border-drag
+release left 40 10
+stable 500 8000
+snap border-done
+# drag the horizontal border up 4 rows (pane 0's new last row)
+press left 20 16
+drag left 20 12
+release left 20 12
+stable 500 8000
+snap hborder-done
+# move pane 1 (its gutter box) into the right column
+press left 1 17
+drag left 70 8
+stable 400 5000
+snap move-preview
+release left 70 8
+stable 500 8000
+snap moved
+# Alt-n: a new shell below the active pane
+key a-n
+stable 700 15000
+snap altn
+# Ctrl-w h: focus left
+key c-w h
+stable 400 5000
+snap focus-left
+# Debug overlay on (topbar middle-click on Debug), then off
+press middle 19 1
+release middle 19 1
+stable 400 8000
+snap debug-on
+snapstyle debug-on
+press middle 19 1
+release middle 19 1
+stable 400 8000
+snap debug-off