diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-29 21:37:26 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-29 22:32:59 -0300 |
| commit | 4d5302edfefea0206ca96726f2fcc254ee228ad3 (patch) | |
| tree | 41df515b46e5652767a96ed206afee35021461b9 | |
| parent | b52e1e7274770fdfdf859938cb01095a8cc1a320 (diff) | |
| download | pardes-4d5302edfefea0206ca96726f2fcc254ee228ad3.tar.gz pardes-4d5302edfefea0206ca96726f2fcc254ee228ad3.zip | |
vibes adding some acme swag to the tty
| -rw-r--r-- | main.zig | 258 | ||||
| -rw-r--r-- | term.zig | 13 | ||||
| -rw-r--r-- | tests.zig | 138 |
3 files changed, 296 insertions, 113 deletions
@@ -18,6 +18,8 @@ extern "c" fn execv( ) c_int; extern "c" fn _exit(status: c_int) noreturn; +extern "c" fn chdir(path: [*:0]const u8) c_int; +extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; // basic file navigation // basic shell io @@ -89,9 +91,10 @@ const red_dim = [3]u8{ 0x66, 0x2a, 0x2a }; // char-mode inserted text (bright on a faint box so it reads as an overlay). const edit_fg = [3]u8{ 0xea, 0xea, 0xea }; const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a }; -// per-button selection highlight: three gray levels, muted text. -const SEL_BG = [3][3]u8{ .{ 0x38, 0x38, 0x38 }, .{ 0x4c, 0x4c, 0x4c }, .{ 0x60, 0x60, 0x60 } }; -const sel_fg = [3]u8{ 0xc0, 0xc0, 0xc0 }; +// per-button selection highlight: distinct (muted) colors so left/middle/right +// selections are easy to tell apart against the otherwise-monochrome chrome. +const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } }; +const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; // Tag builtins. Middle-clicking the name in a window's tag runs the action; // `builtins_str` is the space-joined name list, produced at comptime. @@ -108,12 +111,43 @@ const builtins_str = blk: { break :blk s; }; -fn fork_pty(argv: [:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } { +// Path of the generated bash rc that adds OSC 133 semantic prompts (shell +// integration). bash --rcfile reads it for interactive shells; it sources the +// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That +// marks the prompt rows so we can hide them in text mode. +const rc_path = "/tmp/pardes-osc133.bash"; +const bash_rc = + \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + \\PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]' + \\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C\a"' DEBUG + \\ +; + +fn writeFile(path: [*:0]const u8, data: []const u8) void { + const l = std.os.linux; + const rc = l.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return; + const fd: i32 = @intCast(sfd); + defer _ = l.close(fd); + var off: usize = 0; + while (off < data.len) { + const w = l.write(fd, data[off..].ptr, data.len - off); + const sw: isize = @bitCast(w); + if (sw <= 0) break; + off += @intCast(sw); + } +} + +fn fork_pty(argv: []const ?[*:0]const u8, cwd: ?[*:0]const u8, ws: std.posix.winsize) struct { file: std.Io.File, pid: std.posix.pid_t } { var master: c_int = undefined; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { - const child_argv = [_:null]?[*:0]const u8{ argv.ptr, null }; - _ = execv(argv.ptr, &child_argv); + if (cwd) |c| _ = chdir(c); // start the shell in this directory + var child_argv: [8]?[*:0]const u8 = @splat(null); + for (argv, 0..) |a, i| child_argv[i] = a; + _ = execv(argv[0].?, @ptrCast(&child_argv)); _exit(127); } return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid }; @@ -131,19 +165,33 @@ fn termWrite(t: *Term, bytes: []const u8) void { } } +// The shell's current working directory (via /proc/<pid>/cwd). `buf` holds it. +fn termCwd(t: *Term, buf: []u8) []const u8 { + var pathbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch return "?"; + const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, buf.ptr, buf.len); + return switch (std.posix.errno(rc)) { + .SUCCESS => buf[0..rc], + else => "?", + }; +} + +// True if `path` exists and is a directory. open(O_DIRECTORY) fails with ENOTDIR +// for anything else, so no stat struct needed. +fn isDir(path: [*:0]const u8) bool { + const l = std.os.linux; + const rc = l.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return false; + _ = l.close(@intCast(sfd)); + return true; +} + // The window tag: the shell's cwd followed by the builtin command names. Plain, // selectable text (acme-style). Caller frees with `alloc`. fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 { var link: [1024]u8 = undefined; - var pathbuf: [64]u8 = undefined; - const cwd: []const u8 = cwd: { - const path = std.fmt.bufPrintZ(&pathbuf, "/proc/{d}/cwd", .{t.pid}) catch break :cwd "?"; - const rc = std.os.linux.readlinkat(std.os.linux.AT.FDCWD, path, &link, link.len); - break :cwd switch (std.posix.errno(rc)) { - .SUCCESS => link[0..rc], - else => "?", - }; - }; + const cwd = termCwd(t, &link); // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal). const mode = if (t.editing) "ch" else "sy"; return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str }); @@ -178,8 +226,9 @@ fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop // fork the pty + boot the emulator. Forking is the dangerous part in a // multithreaded process, so this does NOT start the reader task (no new // threads) — call startReader once the term is wired in. -fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16) !*Term { - const child = fork_pty("/usr/bin/bash", .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 }); +fn forkTerm(gpa: std.mem.Allocator, init_cols: u16, init_rows: u16, cwd: ?[*:0]const u8) !*Term { + const argv = [_]?[*:0]const u8{ "/usr/bin/bash", "--rcfile", rc_path }; + const child = fork_pty(&argv, cwd, .{ .row = init_rows, .col = init_cols, .xpixel = 0, .ypixel = 0 }); const t = try gpa.create(Term); errdefer gpa.destroy(t); t.* = .{ @@ -309,24 +358,9 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a tagw.fill(.{ .char = .{ .grapheme = " " }, .style = .{ .bg = .{ .rgb = gray.bar } } }); _ = tagw.printSegment(.{ .text = tag, .style = .{ .fg = .{ .rgb = gray.txt }, .bg = .{ .rgb = gray.bar } } }, .{ .wrap = .none }); - // FUTURE: hide shell prompts, render only command output. ghostty-vt tracks - // OSC 133 semantic prompts and stores the state we'd need: - // - per cell: page.Cell.semantic_content -> .output | .input | .prompt - // - per row: page.Row.semantic_prompt -> .none | .prompt | .prompt_continuation - // - cursor drives it via OSC 133 A/B/C/D (stream_terminal -> Terminal.semanticPrompt). - // Ready-made helpers (all public through ghostty_vt): Screen.selectOutput(pin), - // PageList.highlightSemanticContent(pin, .output), screen.pages.rowIterator(...), - // pin.rowAndCell().cell.semantic_content, pin.promptIterator/cellIterator. - // To do it: swap plainString for a row walk that skips rows where - // row.semantic_prompt != .none (coarse) or non-.output cells (fine): - // var it = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); - // while (it.next()) |pin| { if (pin.rowAndCell().row.semantic_prompt != .none) continue; ... } - // CATCH: only populated if the shell emits OSC 133 (shell integration). bash - // doesn't by default — every cell stays the default .output, so nothing would - // be hidden until we source ghostty's integration script or wrap PS1/ - // PROMPT_COMMAND with the 133;A/B/C/D markers in each spawned pty. Best as a - // toggle (a Hide/Show tag builtin) so it degrades gracefully without integration. // body = tty rows with insertion runs spliced in (text shifts, not overdrawn). + // In text mode bodyText also hides OSC 133 prompt rows (see promptFlags); the + // shells emit OSC 133 via the rc we drop at startup (writeFile/bash_rc). const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try bodyText(alloc, t); var it = std.mem.splitAny(u8, body, "\n"); @@ -382,9 +416,13 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a } if (active) { if (t.editing) { - // char mode: cursor at the rendered cursor position (scroll-adjusted). - const prow = t.cur_row - off + @as(i32, BOX_H); - const cx = renderedCol(t, t.cur_row, t.cur_col, t.cur_off); + // text mode: cursor at the (scroll-adjusted) edit position. until the + // user pins it (click/type) it tracks the shell cursor. + const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off; + const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x)); + const coff = if (t.cur_pinned) t.cur_off else 0; + const prow = crow - off + @as(i32, BOX_H); + const cx = renderedCol(t, crow, ccol, coff); if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER)) text.showCursor(@intCast(cx), @intCast(prow)); } else { @@ -459,13 +497,16 @@ pub fn main(init: std.process.Init) !void { .col_n = @splat(0), }; + // drop the bash shell-integration rc so spawned shells emit OSC 133. + writeFile(rc_path, bash_rc); + // Fork the initial terminals BEFORE starting any io worker thread. forkpty // from a multithreaded process can leave the child wedged before exec; doing // it while still single-threaded avoids that. (initial: two columns, the // left one split into two windows.) - terms[0] = try forkTerm(gpa, screen_w, screen_h); - terms[1] = try forkTerm(gpa, screen_w, screen_h); - terms[2] = try forkTerm(gpa, screen_w, screen_h); + terms[0] = try forkTerm(gpa, screen_w, screen_h, null); + terms[1] = try forkTerm(gpa, screen_w, screen_h, null); + terms[2] = try forkTerm(gpa, screen_w, screen_h, null); layout.ncol = 2; layout.col_n[0] = 2; layout.col_terms[0][0] = 0; @@ -647,9 +688,55 @@ pub fn main(init: std.process.Init) !void { .select => |s| { if (terms[s.id]) |t| { t.sel[@intFromEnum(s.button)].state = .done; + // a no-drag middle/right click expands to the word + // under the cursor (acme button-2/3 behavior). + if (s.button != .left) expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]); if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| { defer gpa.free(txt); - switch (onSelect(s.button, txt)) { + if (s.button == .right) { + // acme "look": if the selected text resolves + // to a directory, open (or focus) a terminal + // there in the same column and ls it. + const word = std.mem.trim(u8, txt, " \t\r\n"); + var cwdbuf: [1024]u8 = undefined; + var joinbuf: [2048]u8 = undefined; + var realbuf: [4096]u8 = undefined; + const cwd = termCwd(t, &cwdbuf); + const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/') + (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null) + else + (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null); + if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) { + const dir = std.mem.span(rp); + // focus an existing terminal on this dir + var found: ?usize = null; + for (&terms, 0..) |*slot, i| if (slot.*) |tt| { + var cb: [1024]u8 = undefined; + if (std.mem.eql(u8, termCwd(tt, &cb), dir)) { + found = i; + break; + } + }; + if (found) |id| { + active = id; + if (terms[id]) |tt| termWrite(tt, "ls\r"); + } else { + const slot: ?usize = for (terms, 0..) |sl, i| { + if (sl == null) break i; + } else null; + if (slot) |free| { + if (forkTerm(gpa, screen_w, screen_h, rp)) |nt| { + terms[free] = nt; + startReader(io, gpa, &loop, nt, free) catch {}; + const f = layoutFindTerm(&layout, s.id).?; + layoutInsert(&layout, f.col, f.idx + 1, free); + active = free; + termWrite(nt, "ls\r"); + } else |_| {} + } + } + }; + } else switch (onSelect(s.button, txt)) { .none => {}, .send => { // run the selection in the terminal it came from @@ -662,7 +749,7 @@ pub fn main(init: std.process.Init) !void { if (sl == null) break i; } else null; if (slot) |free| if (layout.ncol < MAX_COLS) { - if (forkTerm(gpa, screen_w, screen_h)) |nt| { + if (forkTerm(gpa, screen_w, screen_h, null)) |nt| { terms[free] = nt; startReader(io, gpa, &loop, nt, free) catch {}; layoutAppendColumn(&layout, free); @@ -714,6 +801,7 @@ pub fn main(init: std.process.Init) !void { t.cur_row = body_vis + off; t.cur_col = sl.c1; t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0; + t.cur_pinned = true; t.editing = true; } } @@ -736,7 +824,7 @@ pub fn main(init: std.process.Init) !void { if (s == null) break i; } else null; if (free_slot) |slot| { - if (forkTerm(gpa, screen_w, screen_h)) |nt| { + if (forkTerm(gpa, screen_w, screen_h, null)) |nt| { terms[slot] = nt; startReader(io, gpa, &loop, nt, slot) catch {}; const f = layoutFindTerm(&layout, active).?; @@ -762,6 +850,15 @@ pub fn main(init: std.process.Init) !void { // the current run); arrows move the cursor; backspace/etc. // are no-ops for now (deletion comes later). if (key.text) |text| { + // first keystroke pins the cursor (it was tracking the + // shell cursor until now). + if (!at.cur_pinned) { + const coff: i32 = @intCast(at.term.screens.active.pages.scrollbar().offset); + at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff; + at.cur_col = @intCast(at.term.screens.active.cursor.x); + at.cur_off = 0; + at.cur_pinned = true; + } // find or create the run at the cursor var idx: ?usize = runAt(at, at.cur_row, at.cur_col); if (idx == null and at.nedits < Term.MAX_EDITS) { @@ -1055,38 +1152,59 @@ fn spliceRow(alloc: std.mem.Allocator, out: *std.ArrayList(u8), t: *Term, abs_ro // The terminal body with all insertion runs spliced in, newline-joined. This is // the actual on-screen text: render and selection both read it, so there's no // tty-vs-typed distinction. Caller frees. +// Per visible row, whether it's a shell prompt/input row (OSC 133). Used to hide +// prompts in text mode. Rows beyond the iterator (blank tail) stay false. +fn promptFlags(t: *Term, flags: []bool) void { + @memset(flags, false); + var it = t.term.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null); + var i: usize = 0; + while (it.next()) |pin| : (i += 1) { + if (i >= flags.len) break; + flags[i] = pin.rowAndCell().row.semantic_prompt != .none; + } +} + fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 { const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset); const body = try t.term.plainString(alloc); defer alloc.free(body); + // text mode (editing) hides shell prompt rows; tty mode shows everything. + var flagbuf: [512]bool = undefined; + const nrows: usize = @min(t.rows, flagbuf.len); + const flags = flagbuf[0..nrows]; + if (t.editing) promptFlags(t, flags) else @memset(flags, false); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(alloc); // pad to the full grid height: plainString trims trailing blank rows, but we // must still be able to type (and splice) onto those blank rows. var it = std.mem.splitAny(u8, body, "\n"); - const nrows: i32 = @intCast(t.rows); - var i: i32 = 0; + var i: usize = 0; while (i < nrows) : (i += 1) { if (i > 0) try out.append(alloc, '\n'); - const line = it.next() orelse ""; - try spliceRow(alloc, &out, t, i + off, line); // line i shows absolute row i+off + const raw = it.next() orelse ""; + const line = if (flags[i]) "" else raw; // hidden prompt -> blank (edits still splice) + try spliceRow(alloc, &out, t, @as(i32, @intCast(i)) + off, line); } return out.toOwnedSlice(alloc); } +// The full rendered pane: tag at visual row 0, spliced body below. Caller frees. +fn paneText(gpa: std.mem.Allocator, t: *Term) ![]u8 { + const tag = try tagLine(gpa, t); + defer gpa.free(tag); + const body = try bodyText(gpa, t); + defer gpa.free(body); + return std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body }); +} + // Block-selected text, newline-joined per row; caller frees. Reads the rendered -// screen (tag at visual row 0, spliced body below), so selecting typed text and -// tty text works identically. +// screen, so selecting typed text and tty text works identically. fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 { const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1))); const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1))); - const tag = try tagLine(gpa, t); - defer gpa.free(tag); - const body = try bodyText(gpa, t); - defer gpa.free(body); - const pane = try std.fmt.allocPrint(gpa, "{s}\n{s}", .{ tag, body }); + const pane = try paneText(gpa, t); defer gpa.free(pane); var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); @@ -1104,6 +1222,38 @@ fn selectionText(gpa: std.mem.Allocator, t: *Term, sl: Term.Sel) ![]u8 { return out.toOwnedSlice(gpa); } +// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~ +fn isFileChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or switch (c) { + '.', '-', '+', '/', ':', '@', '_', '~' => true, + else => false, + }; +} + +// acme-style: a no-drag middle/right click expands to the file-ish word under the +// cursor, in place. A real (dragged) selection is left untouched. +fn expandSel(gpa: std.mem.Allocator, t: *Term, sl: *Term.Sel) void { + if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return; + const pane = paneText(gpa, t) catch return; + defer gpa.free(pane); + var it = std.mem.splitAny(u8, pane, "\n"); + var v: i32 = 0; + while (it.next()) |line| : (v += 1) { + if (v != sl.r0) continue; + const col: usize = @intCast(@max(0, sl.c0)); + if (col > line.len) return; + var hi = col; + while (hi < line.len and isFileChar(line[hi])) hi += 1; + var lo = col; + while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1; + if (hi > lo) { + sl.c0 = @intCast(lo); + sl.c1 = @intCast(hi - 1); + } + return; + } +} + // Decide what a selection means. Middle button: a builtin name (comptime-matched // against `builtins`) runs that builtin; anything else is sent to the terminal. fn onSelect(button: Button, text: []const u8) SelectAction { @@ -49,14 +49,17 @@ vweight: f32 = 1, /// One independent selection per mouse button: 0=left, 1=middle, 2=right. sel: [3]Sel = .{ .{}, .{}, .{} }, -/// modal input. false = "sym" mode (keys -> pty as terminal sequences), -/// true = "char" mode (keys insert text at the cursor). a left-click enters -/// char mode and anchors the cursor; Esc leaves it. +/// modal input. true = "text"/char mode (DEFAULT; keys insert text at the +/// cursor, shell prompts hidden), false = "tty"/sym mode (keys -> pty as +/// terminal sequences, prompts shown). Esc toggles to tty; a left-click returns +/// to text mode and anchors the cursor. /// /// The cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll /// offset) so they ride the scrollback, not the screen. `cur_off` is the offset -/// into the run sitting at (cur_row, cur_col). -editing: bool = false, +/// into the run sitting at (cur_row, cur_col). `cur_pinned` is false until the +/// user clicks or types, while which the cursor tracks the shell's cursor. +editing: bool = true, +cur_pinned: bool = false, cur_row: i32 = 0, cur_col: i32 = 0, cur_off: u16 = 0, @@ -59,6 +59,29 @@ fn pollFor( return std.mem.indexOf(u8, output.items, needle) != null; } +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); +} + +// Drain the pty for a fixed WALL-CLOCK duration (a real settle). pollFor's +// iter-count "settle" is unreliable for big bursts: reading caps at 4096/iter so +// all iters can be spent reading with no idle wait, making it finish too soon. +fn drainFor(gpa: std.mem.Allocator, master: c_int, output: *std.ArrayList(u8), ms: i64) !void { + const deadline = nowMs() + ms; + var buf: [4096]u8 = undefined; + while (nowMs() < deadline) { + var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 50) catch {}; + if ((fds[0].revents & posix.POLL.IN) != 0) { + const n = posix.read(master, &buf) catch break; + if (n == 0) break; + try output.appendSlice(gpa, buf[0..n]); + } + } +} + fn dump(items: []const u8, msg: []const u8) void { std.debug.print("E2E FAIL: {s}\ncaptured {d} bytes:\n{s}\n", .{ msg, @@ -112,61 +135,66 @@ pub fn main(init: std.process.Init) !void { return error.ResizeNotDelivered; } - // 3. Sym mode (default): keystrokes are forwarded to the embedded shell. - // The tag shows the "sy" mode indicator and the marker echoes back. + // NOTE on polls: vaxis diff-renders, and char-mode text is built one keystroke + // per frame, so inserted chars are emitted across frames and never appear as a + // contiguous byte run. We `settle` (drain so input is processed, also avoiding + // a resize race), force a full repaint with setWinsize (re-emits whole rows + // contiguously), then poll. Command *output* lands in one frame already. + + // 3. Default is TEXT mode: the tag shows "ch" and shell prompts are hidden. + if (!try pollFor(gpa, master, &output, "ch /", 60)) { + dump(output.items, "default tag is not 'ch' (text mode)"); + return error.TextModeNotDefault; + } + + // 4. Esc switches to TTY mode and forwards keystrokes to the shell. The settle + // lets vaxis emit a *standalone* Escape (lone ESC + text would be Alt+char). const marker = "PARDES_E2E"; + try writeAll(master, "\x1b"); + _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle try writeAll(master, "echo " ++ marker ++ "\r"); if (!try pollFor(gpa, master, &output, marker, 80)) { - dump(output.items, "app did not render echoed marker"); - return error.MarkerNotRendered; + dump(output.items, "tty mode did not forward the marker to the shell"); + return error.TtyModeNoForward; } - if (!try pollFor(gpa, master, &output, "sy ", 20)) { - dump(output.items, "no 'sy' mode indicator on the tag in sym mode"); - return error.ModeIndicatorMissing; + try setWinsize(master, 50, 201); // repaint so the whole tag re-emits contiguously + if (!try pollFor(gpa, master, &output, "sy /", 40)) { + dump(output.items, "no 'sy' indicator after Esc (tty mode)"); + return error.TtyModeIndicatorMissing; } - // NOTE on the polls below: vaxis diff-renders, and char-mode text is built - // one keystroke per frame, so the inserted chars are emitted across frames - // and never appear as a contiguous byte run. We therefore `settle` (drain so - // the input is fully processed, which also avoids racing the resize), force a - // full repaint with setWinsize (which re-emits whole rows contiguously), and - // only then poll. Command *output* (e.g. R42Q) lands in one frame already, so - // it needs no repaint. - - // 4. A left-click enters char mode and typed text is inserted as an overlay. - // SGR mouse: press+release of button 0 at a body cell (col 6, row 3). - try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m"); - try writeAll(master, "EDITZZ1"); + // 5. Prompt hiding. In tty mode a command line + its output are both visible. + // Switching back to text mode hides the prompt/command rows but keeps the + // output. We clear the capture, switch with a left-click, force a repaint, + // then check the fresh frame: output present, prompt + command absent. + try writeAll(master, "echo CMDHIDEZZ\r"); + if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) { + dump(output.items, "tty mode did not run the command"); + return error.TtyCommandFailed; + } + _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // drain all tty-mode frames + output.clearRetainingCapacity(); // so the next poll only sees the text-mode repaint + try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> text mode _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle - try setWinsize(master, 50, 201); // force a full repaint - if (!try pollFor(gpa, master, &output, "EDITZZ1", 80)) { - dump(output.items, "char-mode insertion did not render"); - return error.EditNotRendered; + try setWinsize(master, 50, 202); // force a full repaint (text mode, prompts hidden) + if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) { + dump(output.items, "command output not shown in text mode"); + return error.OutputHiddenInTextMode; } - - // 5. The same repaint confirms the mode flipped sy -> ch on the tag. If char - // mode never engaged, the marker went to the shell and the tag reads "sy". - if (!try pollFor(gpa, master, &output, "ch /", 80)) { - dump(output.items, "left-click did not switch the tag to 'ch' (char mode)"); - return error.CharModeNotEntered; + if (std.mem.indexOf(u8, output.items, "goblin@") != null) { + dump(output.items, "shell prompt still visible in text mode"); + return error.PromptNotHidden; } - - // 6. A second click anchors a second independent insertion run. - try writeAll(master, "\x1b[<0;6;5M\x1b[<0;6;5m"); - try writeAll(master, "EDITZZ2"); - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle - try setWinsize(master, 50, 202); // force a full repaint - if (!try pollFor(gpa, master, &output, "EDITZZ2", 80)) { - dump(output.items, "second anchored insertion did not render"); - return error.SecondEditNotRendered; + if (std.mem.indexOf(u8, output.items, "echo CMDHIDEZZ") != null) { + dump(output.items, "command line still visible in text mode"); + return error.CommandNotHidden; } - // 6b. Middle-click executes the typed text read off the rendered screen. - // Char-click a blank body row, type a computed echo as an insertion, then - // middle-select exactly those 15 cells (text col 0 -> screen cols 2..16) - // and release. The result "Q7Z" appears only if the SELECTED inserted - // text ran — proving selection sees typed text, not the tty beneath it. - try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // char-click blank row 13 + // 6. Text-mode editing + middle-click execute. Click a blank row to pin the + // cursor at text col 0, compose a computed echo, then middle-select those + // 15 cells (screen cols 2..16) and release. "Q7Z" appears only if the + // SELECTED typed text ran — proving selection reads the rendered screen. + try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0 try writeAll(master, "echo Q$((3+4))Z"); _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle (build the run) try writeAll(master, "\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it @@ -175,17 +203,19 @@ pub fn main(init: std.process.Init) !void { return error.MiddleExecFailed; } - // 7. Esc leaves char mode (tag back to sy) and input resumes going to the - // pty: a computed echo's result only appears if the shell executed it. - // (In char mode the literal "$((20+22))" would render but never "R42Q".) - // The settle after Esc lets vaxis emit a *standalone* Escape — a lone ESC - // immediately followed by text is otherwise parsed as Alt+<char>. + // 7. Right-click a directory opens (acme "look") a terminal there and ls's + // it. Make a dir with a unique entry, then `clear` and echo its path so the + // path lands as contiguous output on body row 0 (screen row 1). Right-click + // (no drag) on it: the new terminal lists the dir, so the unique entry + // appears only if the directory was actually opened. try writeAll(master, "\x1b"); - _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle - try writeAll(master, "echo R$((20+22))Q\r"); - if (!try pollFor(gpa, master, &output, "R42Q", 80)) { - dump(output.items, "Esc did not return to sym mode (computed echo never ran)"); - return error.SymModeNotRestored; + try drainFor(gpa, master, &output, 700); // settle (standalone Esc -> tty) + try writeAll(master, "rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r"); + try drainFor(gpa, master, &output, 2000); // let it run + the screen fully settle + try writeAll(master, "\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1 + if (!try pollFor(gpa, master, &output, "ZZUNIQDIR", 120)) { + dump(output.items, "right-click on a directory did not open+ls it"); + return error.RightClickDirFailed; } // Tell the app to quit (ctrl-c is the app's own exit key). |
