summaryrefslogtreecommitdiff
path: root/src/tty.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 20:21:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitde4def3548a6729b0dfd2120495a61beef8c8c2c (patch)
tree12144f0f64dc96bb4741459c1f9db57f44349330 /src/tty.zig
parent7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff)
downloadpardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz
pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'src/tty.zig')
-rw-r--r--src/tty.zig386
1 files changed, 386 insertions, 0 deletions
diff --git a/src/tty.zig b/src/tty.zig
new file mode 100644
index 00000000..d9821343
--- /dev/null
+++ b/src/tty.zig
@@ -0,0 +1,386 @@
+//! The terminal shell: owns the event loop and all IO. Translates vaxis
+//! events into core events, performs the core's effects (fork ptys, write
+//! them, resize them), and hands the core's Surface to vaxis cell-for-cell —
+//! the canonical interface rendered with no interpretation.
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.zig");
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+pub const Command = struct {
+ pub var value: union(enum) {
+ nop,
+ quit,
+ tick,
+ key_press: vaxis.Key,
+ pty_read: struct { id: usize, bytes: []u8 },
+ pty_eof: usize,
+ winsize: vaxis.Winsize,
+ mouse: vaxis.Mouse,
+ paste: []const u8,
+ } = .nop;
+};
+const Loop = vaxis.Loop(@TypeOf(Command.value));
+
+const Pty = struct {
+ file: std.Io.File,
+ pid: posix.pid_t,
+ reader: std.Io.Future(anyerror!void),
+};
+
+pub fn run(init: std.process.Init, opts: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ var tty_buf: [0x10000]u8 = undefined;
+ var tty = try vaxis.Tty.init(io, &tty_buf);
+ var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
+ defer vx.deinit(gpa, tty.writer());
+ try vx.enterAltScreen(tty.writer());
+ defer vx.exitAltScreen(tty.writer()) catch {};
+ try vx.setMouseMode(tty.writer(), true);
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+
+ // stb_image allocator for image panes
+ pardes.image.start(io, gpa);
+ defer pardes.image.stop();
+
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+
+ // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
+ writeFile(pardes.bash_rc);
+
+ var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null);
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |*pt| {
+ _ = linux.close(pt.file.handle);
+ slot.* = null;
+ };
+
+ // Perform the initial spawns BEFORE any worker thread exists: forkpty from
+ // a multithreaded process can wedge the child before exec.
+ var loop: Loop = .init(io, &tty, &vx);
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, false);
+
+ try loop.start();
+ defer loop.stop();
+ try loop.installResizeHandler();
+ vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {};
+
+ // now threads are fine: start a reader task per pty
+ for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| {
+ pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, &loop });
+ };
+
+ var pending: ?@TypeOf(Command.value) = .tick;
+ while (!core.quit) {
+ const event = if (pending) |ev| blk: {
+ pending = null;
+ break :blk ev;
+ } else try loop.nextEvent();
+ switch (event) {
+ .nop, .tick => {},
+ .quit => break,
+ .winsize => |ws| {
+ try vx.resize(gpa, tty.writer(), ws);
+ core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
+ },
+ .pty_read => |pr| {
+ core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ gpa.free(pr.bytes);
+ },
+ .pty_eof => |id| {
+ if (ptys[id]) |*pt| {
+ _ = linux.close(pt.file.handle);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = @intCast(id) } });
+ },
+ .key_press => |key| core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } }),
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none, // button-less motion: hover tracking
+ else => null,
+ };
+ if (button) |b| core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ } });
+ },
+ .paste => |bytes| {
+ core.update(.{ .paste = bytes });
+ gpa.free(@constCast(bytes));
+ },
+ }
+
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true);
+ if (core.quit) break;
+
+ // live cwd for tags/look: cheap /proc readlink per pane, per frame
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| {
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue;
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]);
+ };
+
+ // ---- render: surface -> vaxis, cell for cell ----
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ const win = vx.window();
+ win.clear();
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const cell = surface.at(x, y);
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+ // pixel attachments (kitty graphics): transmit once per pane, then
+ // re-place every frame (placements aren't persistent)
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) {
+ const enc = std.base64.standard.Encoder;
+ if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
+ defer gpa.free(b64);
+ _ = enc.encode(b64, place.rgba);
+ kitty_handles[place.pane] = vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null;
+ } else |_| {}
+ }
+ if (kitty_handles[place.pane]) |h| {
+ const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
+ h.draw(child, .{ .scale = .contain }) catch {};
+ }
+ }
+ if (surface.cursor) |cur| win.showCursor(cur.x, cur.y);
+ try vx.render(tty.writer());
+ }
+}
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ loop: *Loop,
+ vx: *vaxis.Vaxis,
+ tty: *vaxis.Tty,
+ threads_ok: bool,
+) void {
+ while (core.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| {
+ std.debug.assert(ptys[sp.pane] == null);
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const child = forkShell(cwd_z, core.screen_h, core.screen_w);
+ ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
+ // report the pane's starting directory back to the core (tags)
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{child.pid}, 0) catch null) |path| {
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]);
+ }
+ if (threads_ok) {
+ if (ptys[sp.pane]) |*pt| {
+ pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), loop }) catch pt.reader;
+ }
+ }
+ },
+ .write => |w| {
+ if (ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => {}, // tty: no link handling (look opens panes instead)
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, f.content);
+ _ = linux.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, out);
+ _ = linux.close(fd);
+ },
+ .set_clipboard => {
+ // mirror the core's yank register out via OSC 52
+ if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
+ },
+ .get_clipboard => vx.requestSystemClipboard(tty.writer()) catch {},
+ .quit => {},
+ };
+}
+
+fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+ 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) {
+ if (cwd) |c| _ = chdir(c);
+ const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv("/usr/bin/bash", &argv);
+ _exit(127);
+ }
+ return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
+}
+
+fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
+ var read_buf: [0x10000]u8 = undefined;
+ var reader = pty.readerStreaming(io, &read_buf);
+ while (true) {
+ var buf: [0x10000]u8 = undefined;
+ var vec = [_][]u8{&buf};
+ const n = reader.interface.readVec(&vec) catch break;
+ if (n == 0) break;
+ const bytes = try gpa.dupe(u8, buf[0..n]);
+ loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
+ gpa.free(bytes);
+ break;
+ };
+ }
+ loop.postEvent(.{ .pty_eof = id }) catch {};
+}
+
+/// The effective codepoint the way vaxis Key.matches sees it: a single-char
+/// text wins (shift resolved by the terminal), else the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |c| {
+ if (it.nextCodepoint() == null) return c;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
+/// coincide: enter/tab/escape/backspace pass through).
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ else => cp,
+ };
+}
+
+fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
+ return .{
+ .fg = vaxisColor(s.fg),
+ .bg = vaxisColor(s.bg),
+ .bold = s.bold,
+ .dim = s.dim,
+ .italic = s.italic,
+ .blink = s.blink,
+ .reverse = s.reverse,
+ .invisible = s.invisible,
+ .strikethrough = s.strikethrough,
+ .ul_style = switch (s.ul) {
+ .off => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+}
+
+fn vaxisColor(c: pardes.Color) vaxis.Color {
+ return switch (c) {
+ .default => .default,
+ .index => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = rgb },
+ };
+}
+
+fn writeFile(data: []const u8) void {
+ const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const rc = linux.write(fd, data[off..].ptr, data.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}