summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-29 21:37:26 -0300
committerGabriel Schneider <[email protected]>2026-06-29 22:32:59 -0300
commit4d5302edfefea0206ca96726f2fcc254ee228ad3 (patch)
tree41df515b46e5652767a96ed206afee35021461b9
parentb52e1e7274770fdfdf859938cb01095a8cc1a320 (diff)
downloadpardes-4d5302edfefea0206ca96726f2fcc254ee228ad3.tar.gz
pardes-4d5302edfefea0206ca96726f2fcc254ee228ad3.zip
vibes adding some acme swag to the tty
-rw-r--r--main.zig258
-rw-r--r--term.zig13
-rw-r--r--tests.zig138
3 files changed, 296 insertions, 113 deletions
diff --git a/main.zig b/main.zig
index 181c2f58..17ee6b87 100644
--- a/main.zig
+++ b/main.zig
@@ -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 {
diff --git a/term.zig b/term.zig
index d5030e5b..307b116f 100644
--- a/term.zig
+++ b/term.zig
@@ -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,
diff --git a/tests.zig b/tests.zig
index 1d5e8c4c..eeb397fa 100644
--- a/tests.zig
+++ b/tests.zig
@@ -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).