//! 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 builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.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; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; // absolute shell path per OS: execv must not search PATH (no allocation // between fork and exec) const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; // TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); 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: struct { id: usize, gen: u32 }, winsize: vaxis.Winsize, mouse: vaxis.Mouse, paste: []const u8, /// a language query finished on a worker; rows are gpa-owned lsp_done: struct { id: u32, rows: []u8 }, /// something happened in a watched directory (see watchFiles) files_changed, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); /// The files on open panes, watched through ONE inotify instance. /// /// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the /// file: almost nothing rewrites a file in place. An editor, `git checkout`, a /// formatter — they write a temp file and rename it over the target, which /// swaps the inode, and a watch on the file itself keeps reporting on the old /// unlinked one forever. The directory sees the new inode arrive (MOVED_TO / /// CREATE). std.Build.Watch marks directories for exactly this reason; it uses /// fanotify because it watches thousands of them across mount points and needs /// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the /// code, hands us no privilege question, and has no kernel version floor. /// /// `hash` is what we last saw ON DISK. A reload only fires when it moves, so /// pardes's own Save — which restamps the hash from the bytes it just wrote — /// never looks like an external change and never touches the undo stack. /// A hash rather than mtime+size because we have to read the file to hand the /// core its bytes anyway, so the exact answer costs nothing extra and there is /// no clock granularity to reason about. const Watch = struct { wd: c_int, hash: u64 }; /// One language query, owned by the worker that runs it. Everything the /// backend may read is copied in here before the worker starts: the core goes /// on editing the moment the effect is drained, so a borrowed slice would be a /// use-after-free the length of one keystroke. const LspJob = struct { id: u32, kind: pardes.lsp.Kind, offset: u32, path: []u8, source: [:0]u8, arg: []u8, root: []u8, fn free(j: *LspJob, gpa: std.mem.Allocator) void { gpa.free(j.path); gpa.free(j.source); gpa.free(j.arg); gpa.free(j.root); gpa.destroy(j); } }; 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; // SIGWINCH must never run vaxis's signal handler: it posts the winsize // event through std.Io.Mutex/Condition, and when the signal lands on a // thread blocked inside an Io.Threaded syscall region (pty readers in // read(2), the main thread parked in queue.pop) a contended lock re-enters // the Io machinery and Syscall.start hits `unreachable` — panic, then the // panic-time terminal restore used to write through the same Io and // recurse until stack overflow. Reproduced by resizing the outer terminal // (e.g. a font-size change) while shells run. Block it here, before any // thread exists (threads inherit the mask, so vaxis's handler never // fires), and take it synchronously on the sigwait thread below instead. var winch_set = posix.sigemptyset(); posix.sigaddset(&winch_set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.BLOCK, &winch_set, null); var tty_buf: [0x10000]u8 = undefined; var tty = try vaxis.Tty.init(io, &tty_buf); defer tty.deinit(); // restore cooked termios LAST, after vx flushed its resets 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 {}; // requests 1002;1003;1004;1006 (cell-coordinate SGR; called pre-query, so // vaxis never upgrades to 1016 pixel mode). Note: ghostty's GTK apprt drops // middle press+release BEFORE mouse reporting when the desktop sets // gtk-enable-primary-paste=false — no mode we request can surface middle // clicks there (see test/snapshots/ghostty-mid.snap). 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(); var core = if (opts.load_path) |lp| blk: { const bytes = try look.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); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: a reused pane id ignores the old shell's // late pty_eof (which would otherwise close the NEW pty on that slot) var gens: [pardes.MAX_PANES]u32 = @splat(0); // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; // One inotify instance for every watched pane, opened here — before any // thread exists — so the pre-loop effect drain below can already mark the // file a positional path argument opened. -1 off linux: watchPane goes // quiet and the core simply never gets a file_changed event. var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; var watches: [pardes.MAX_PANES]?Watch = @splat(null); var watch_task: ?std.Io.Future(anyerror!void) = null; var loop: Loop = .init(io, &tty, &vx); defer { // reap the reader tasks (cancel interrupts a blocked read) before // closing the masters — the runtime joins those threads on exit and a // reader stuck in read(2) would hang the process — then drain the // queue: leftover events own gpa bytes and would dump as leaks. for (&ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; }; // join the query worker BEFORE the drain below, or its late post // lands in a queue nobody empties again and the rows leak if (lsp_task) |*t| { t.cancel(io) catch {}; lsp_task = null; } // same contract as the pty readers: cancel unblocks the watcher's // read, and only then is the fd safe to close if (watch_task) |*t| { t.cancel(io) catch {}; watch_task = null; } if (inotify_fd >= 0) { _ = libc.close(inotify_fd); inotify_fd = -1; } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), .lsp_done => |d| gpa.free(d.rows), else => {}, }; } // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, false); try loop.start(); defer loop.stop(); // resize watcher: plain detached thread (not io.concurrent — teardown // joins those, and sigwait never returns); dies with the process (try std.Thread.spawn(.{}, winchWatch, .{ &loop, &vx, &tty })).detach(); // Capability handshake — SEND the probes, do not wait on them. This was // queryTerminal(2ms), which blocks on a futex until DA1 comes back. The // number has to beat one terminal round trip: a local terminal answers in // microseconds, `ssh localhost` in under 1ms, and any real link never. // Measured over sshd on :22 with the replies delayed to model the wire, // 2ms already loses at 5ms RTT and everything above. // // Losing it is worse than never probing, because vaxis splits detect from // enable and only detect respects the deadline. queryTerminal sets // queries_done the moment the futex times out, and the two replies vaxis // gates on that flag — explicit width and scaled text, both answered as a // cursor-position report — stop being recognised as probe replies and are // handed to US as shift-F3/alt-F3 keypresses. The replies it does NOT // gate (mode 2027, kitty keyboard/graphics, sgr-pixels) keep landing and // keep mutating vx.caps from the reader thread, long after // enableDetectedFeatures ran and declined to switch those modes on. So // over ssh the terminal sat in its default modes while caps claimed // otherwise — kitty keyboard was never actually pushed, ever. Raising the // timeout only moves the link speed at which that happens. // // Resolve it on the loop instead. DA1 is last in the probe string and // terminals answer in order, so when vaxis's reader flips queries_done // every earlier reply is already applied — no window left to miss at any // latency, and the enable lands before the next frame (see caps_pending // in the render path). A terminal that never answers keeps the defaults, // which is what the 2ms timeout produced anyway, and with no caps // enableDetectedFeatures writes no bytes — the snapshot goldens, where // nothing ever answers, do not move. Startup gets 2ms faster, not slower. // // ponytail: nothing wakes the loop for DA1 alone. In practice the reply // burst carries the mode-2048 size report too, which posts a winsize and // turns the loop; a terminal idle from boot that lands DA1 between two // parks keeps the defaults until the user's first keystroke (decoded // legacy, which vaxis handles). Ceiling accepted because nothing in the // render path reads the missing caps: pardes writes one codepoint per // cell plus an explicit blank spacer under a wide glyph, and vaxis's // Cell.width defaults to 1, so gwidth — the only consumer of // caps.unicode — is never called. If that ever changes, wake the loop on // vx.query_futex from a one-shot thread instead. try vx.queryTerminalSend(tty.writer()); var caps_pending = true; // 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, gens[id], &loop }); }; // ...and the one file watcher. Started here rather than lazily on the // first watched pane because the fd already exists and an unwatched // inotify instance just parks in read(2) — one thread for the process, // however many panes come and go. if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { var event = if (pending) |ev| blk: { pending = null; break :blk ev; } else try loop.nextEvent(); const tz_event = tracy.zone(@src(), "event"); // Apply every queued INPUT event, then render ONCE — the gui shell // drains SDL's queue the same way. Without this a wheel flick is fifty // full render+repaint (and re-highlight) cycles instead of one. // pty output keeps a frame per chunk: progress paints as it arrives. var stop = false; var batch: usize = 0; while (true) { var output = false; switch (event) { .nop, .tick => {}, .quit => stop = true, .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); output = true; }, .pty_eof => |e| if (gens[e.id] == e.gen) { if (ptys[e.id]) |*pt| { pt.reader.await(io) catch {}; // reader just finished; join it or its future leaks _ = libc.close(pt.file.handle); ptys[e.id] = null; } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, .key_press => |key| core.update(.{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }), .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, .wheel_right => .wheel_right, .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), .ctrl = m.mods.ctrl, } }); }, .paste => |bytes| { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, // Coalesced on purpose: a burst of writes (a formatter, a // build, a `git checkout`) collapses into ONE pass below, so // it cannot queue a reload — or an undo entry — per write. .files_changed => check_files = true, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); gpa.free(d.rows); // the worker is finished; join it so its future does not // leak (same contract as pty_eof above) if (lsp_task) |*t| { t.await(io) catch {}; lsp_task = null; } }, } batch += 1; if (stop or output or batch >= 64) break; event = (try loop.tryEvent()) orelse break; } tz_event.end(); if (stop) break; // A watched directory moved. The wake says only THAT something // happened, so re-read every watched pane and hand the core the ones // whose bytes actually changed — that comparison is what keeps our own // Save, and any write that lands on identical content, out of the undo // stack. Reading here rather than on the watcher thread because the // core is the only thing that knows which pane a path belongs to, and // opening a file pane already reads synchronously on this thread. if (check_files) { check_files = false; for (&watches, 0..) |*slot, id| { if (slot.* == null) continue; const pane = core.panes[id] orelse continue; const f = pane.file orelse continue; const bytes = look.readFile(gpa, f.path) catch continue; defer gpa.free(bytes); const h = std.hash.Wyhash.hash(0, bytes); if (h == slot.*.?.hash) continue; slot.*.?.hash = h; core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); } } drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live // shells die with their masters (readers canceled, gens bumped so // their late eofs never touch the replay panes) if (core.takeRestore()) |rp| blk: { const bytes = look.readFile(gpa, rp) catch break :blk; defer gpa.free(bytes); var o = core.opts; o.cols = core.screen_w; o.rows = core.screen_h; // pre-size: dump panes never greet const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk; for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; gens[pid] +%= 1; }; // the replay core's pane ids mean new things, and the dying core's // `watch off` effects go into a queue nobody drains — drop the lot // here. The new core emits its own `on`s as it builds its panes. for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); kitty_handles = @splat(null); core.deinit(); core = nc; } // live cwd for tags/look: cheap per-pane lookup, per frame for (&ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; // The handshake landed (vaxis's reader flips queries_done on DA1, the // last probe answered): put the terminal into the modes the caps now // claim, before anything is drawn under them. Polled here rather than // done where the replies arrive because that is the reader thread, and // this is the only thread allowed to touch the tty writer. The repaint // matters as much as the enable: earlier frames were drawn under the // pre-handshake caps, and vaxis's shadow grid has to be re-established // under the new ones or it keeps skipping cells it thinks are current. if (caps_pending and vx.queries_done.load(.unordered)) { caps_pending = false; vx.enableDetectedFeatures(tty.writer()) catch {}; vx.queueRefresh(); } // ---- render: surface -> vaxis, cell for cell ---- _ = frame_arena.reset(.retain_capacity); const tz_core = tracy.zone(@src(), "core.render"); const surface = try core.render(frame_arena.allocator()); tz_core.end(); const tz_cells = tracy.zone(@src(), "surface->vaxis"); 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), }); } } tz_cells.end(); // 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); // insert = beam, everything else = the terminal's default shape win.setCursorShape(if (cur.bar) .beam else .default); } const tz_render = tracy.zone(@src(), "vx.render"); try vx.render(tty.writer()); tz_render.end(); tracy.frameMark(); } } fn drainEffects( core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, io: std.Io, gpa: std.mem.Allocator, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, lsp_task: *?std.Io.Future(anyerror!void), inotify_fd: c_int, watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { .spawn => |sp| { // the core reuses pane ids and there is no close effect: a deleted // pane's shell lives in its slot until a respawn lands here — reap // it (cancel joins the reader; its late eof is ignored by gen) if (ptys[sp.pane]) |*old| { old.reader.cancel(io) catch {}; _ = libc.close(old.file.handle); ptys[sp.pane] = null; } gens[sp.pane] +%= 1; 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; if (look.shellCwd(child.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) { if (ptys[sp.pane]) |*pt| { pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), gens[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, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => |url| look.openLink(url.slice()), // desktop browser .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 fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) continue; writeFd(fd, f.content); _ = libc.close(fd); // our own write is about to come back as a watch event: restamp // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, .write_dump => { const out = core.dump_out orelse continue; var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse continue; const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) continue; writeFd(fd, out); _ = libc.close(fd); core.setLastDump(path); }, .set_clipboard => { // mirror the core's yank register out via OSC 52 if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; }, .lsp => |q| { if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet const pane = core.panes[q.pane] orelse continue; // a pane with no file still asks `status` (it is about the backend, // not the buffer): empty path and source, root from the pane's cwd const f = pane.file; const job = gpa.create(LspJob) catch continue; job.* = .{ .id = q.id, .kind = q.kind, .offset = q.offset, .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { gpa.destroy(job); continue; }, .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { gpa.free(job.path); gpa.destroy(job); continue; }, .arg = gpa.dupe(u8, q.arg.slice()) catch { gpa.free(job.path); gpa.free(job.source); gpa.destroy(job); continue; }, .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { gpa.free(job.path); gpa.free(job.source); gpa.free(job.arg); gpa.destroy(job); continue; }, }; // ponytail: ONE query in flight, so one future slot. Replacing it // cancels-then-joins the previous worker, which for a backend that // ignores cancellation means waiting out a query the user already // abandoned. Queries are milliseconds; make this a real pool the // day a backend takes long enough to notice. if (lsp_task.*) |*old| { old.cancel(io) catch {}; lsp_task.* = null; } lsp_task.* = io.concurrent(lspWorker, .{ gpa, job, loop }) catch { job.free(gpa); continue; }; }, .watch => |w| { // starting, the path and the on-disk bytes are read off the core // (same split as save_file); stopping, the pane is already gone var path: ?[]const u8 = null; var hash: u64 = 0; if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { path = f.path; hash = std.hash.Wyhash.hash(0, f.content); }; watchPane(inotify_fd, watches, w.pane, path, hash); }, .quit => {}, }; } /// Answer a language query off the event loop and post the rows back. This is /// the whole async execution model: the same shape as readPty — do the slow /// thing on a worker, hand the result to the loop as an event, let the core /// stay a state machine that never blocks. fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { defer job.free(gpa); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); // the shell owns the result buffer; the backend only ever writes to it var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); pardes.lsp.query(gpa, arena.allocator(), .{ .kind = job.kind, .path = job.path, .source = job.source, .offset = job.offset, .arg = job.arg, .root = job.root, }, &out.writer); const rows = gpa.dupe(u8, out.written()) catch return; loop.postEvent(.{ .lsp_done = .{ .id = job.id, .rows = rows } }) catch gpa.free(rows); return; } /// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any /// other OS this returns silently, the core never receives a file_changed /// event, and the feature is simply off — a state the core has to tolerate /// regardless, since the browser shell has no filesystem at all. /// ponytail: darwin wants the FSEvents half of std.Build.Watch here. fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { if (comptime builtin.os.tag != .linux) return; if (fd < 0) return; if (watches[id]) |old| { // inotify hands out ONE descriptor per directory, so two panes on // files in the same directory share it: drop the mark only when the // last of them lets go, or closing one blinds the other. var shared = false; for (watches, 0..) |other, i| { const o = other orelse continue; if (i != id and o.wd == old.wd) shared = true; } if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); watches[id] = null; } const p = path orelse return; const dir = std.fs.path.dirname(p) orelse "."; var dbuf: [4096:0]u8 = undefined; if (dir.len >= dbuf.len) return; @memcpy(dbuf[0..dir.len], dir); dbuf[dir.len] = 0; // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one // per write(2), which is most of the debounce for free. MOVED_TO and // CREATE catch the rename-over and the delete-then-recreate that are how // files are actually replaced (see Watch). const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); if (wd < 0) return; watches[id] = .{ .wd = wd, .hash = hash }; } /// Block on the inotify fd and wake the loop. Deliberately does NOT parse the /// events: the loop re-reads every watched pane anyway, so the only thing an /// event carries that we need is THAT something happened, and parsing would /// mean sharing the watch table with the thread that mutates it. Same shape as /// readPty — block off the loop, hand the loop an event, keep the core a state /// machine that never blocks. Going through std.Io.File rather than a raw /// read(2) is what lets the teardown `cancel` interrupt it. /// /// ponytail: churn in a watched directory that never touches the watched file /// still costs a wake and a re-read per event. The ceiling is one directory /// per open file pane; filter by basename here if it ever shows up in a /// profile. fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; var read_buf: [4096]u8 = undefined; var reader = file.readerStreaming(io, &read_buf); while (true) { var buf: [4096]u8 = undefined; var vec = [_][]u8{&buf}; const n = reader.interface.readVec(&vec) catch break; if (n == 0) break; loop.postEvent(.files_changed) catch break; } } /// Consume SIGWINCH synchronously (it is blocked in every thread) and post /// the new size as a winsize event from normal thread context — the one place /// vaxis's Io-backed queue is safe to touch on a resize. fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); while (true) { var sig: c_int = 0; if (libc.sigwait(&set, &sig) != 0) continue; if (vx.state.in_band_resize) continue; // terminal reports via CSI 48 const ws = tty.getWinsize() catch continue; loop.postEvent(.{ .winsize = ws }) catch {}; } } 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) { // the blocked-SIGWINCH mask survives fork AND exec — unblock it or // bash/vim in the pane would never see resizes (sigprocmask is // async-signal-safe) var set = posix.sigemptyset(); posix.sigaddset(&set, posix.SIG.WINCH); posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); if (cwd) |c| _ = chdir(c); const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; _ = execv(bash_path, &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, gen: u32, 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; }; } // non-blocking: a teardown cancel only unblocks one wait, so a blocking // post into a full queue here could hang the exit _ = loop.tryPostEvent(.{ .pty_eof = .{ .id = id, .gen = gen } }) 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, vaxis.Key.delete => pardes.Key.delete, 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 fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return; defer _ = libc.close(fd); writeFd(fd, data); } fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return; } off += @intCast(n); } }