//! 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. //! //! It also holds the OTHER loop a terminal can run: an attached frontend, //! which has a socket where its core would be and performs no machine-local //! effect whatsoever (see `Attach`). The `Attach` builtin turns the first into //! the second in place, without giving up the terminal. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); const look = @import("../look.zig"); const shell_bin = @import("../shell_bin.zig"); const message = @import("../message.zig"); const file_watch = @import("../file_watch.zig"); const user_config = @import("../user_config.zig"); const selection_pipe = @import("../selection_pipe.zig"); const nested = @import("../nested.zig"); const fuse = @import("../fuse.zig"); const fs_service = @import("../fs_service.zig"); const panel_compositor = @import("panel_compositor.zig"); const host_api = @import("../host.zig"); // The other half of `--detach`, and the reason this file has an attached loop // at all: the frontend side of a detached session is a terminal and a socket, // and this file is already the one that owns a terminal. const detached_client = @import("../detached/client.zig"); const detached_server = @import("../detached/server.zig"); const wire = @import("../detached/wire.zig"); const host_io = @import("../host_io.zig"); extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; // 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, /// Focus reporting is part of vaxis's mouse mode (DEC 1004). A TTY /// cannot report a literal pointer crossing its character grid, so /// losing terminal focus is its only reliable pointer-leave signal. focus_in, focus_out, paste: []const u8, /// The bracketed-paste brackets. vaxis posts them ONLY because this /// union declares fields with these exact names — its Loop gates every /// event on `@hasField` — and the pasted bytes themselves arrive /// BETWEEN them as ordinary key presses, which the loop accumulates /// into one `.paste` above instead of running as commands. paste_start, paste_end, /// a language query finished on a worker; rows are lsp-domain-owned lsp_done: struct { id: u32, rows: []u8 }, /// a language SERVER changed state (spawned, indexing, exited) — the /// client's reader thread narrates and this lands it on the message /// row; text is lsp-domain-owned lsp_status: []u8, /// a selection-filter worker finished; every stdout is gpa-owned pipe_done: selection_pipe.Response, /// something happened in a watched directory (see watchFiles) files_changed, /// a pardes launched inside this one sent us a builtin command line /// (see lookServer); gpa-owned, like pty_read command: []u8, /// `--fs`: the /dev/fuse descriptor has requests on it. Carries /// nothing and is applied as a no-op — its whole job is to end the /// blocking `nextEvent`, because the drain itself lives in pollFrame /// beside the file-watch reload. Same shape and same reason as /// `files_changed` above, and posted from two places: the poll thread /// when the kernel makes the descriptor readable, and pollFrame itself /// when a batch hit its cap with requests still pending. fs_ready, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); /// The shared snapshot/worker pair every native shell uses. This file used to /// carry its own `LspJob` and gui.zig carried a copy of it; the copies said so. const lsp_host = @import("../lsp_host.zig"); /// The pipe in-flight set moved to `selection_pipe.Tasks`, beside the Job it /// tracks: gui.zig carried this same table verbatim. const PipeTask = selection_pipe.Tasks.Task; const PipeTasks = selection_pipe.Tasks; const Pty = struct { file: std.Io.File, pid: posix.pid_t, reader: std.Io.Future(anyerror!void), }; const KittyPlacement = struct { cell_x: u16, cell_y: u16, cell_cols: u16, cell_rows: u16, options: vaxis.Image.DrawOptions, }; fn terminalCellPixels(total_pixels: u16, cells: u16, fallback: u32) u32 { if (total_pixels == 0 or cells == 0) return fallback; return @max(1, @as(u32, total_pixels) / cells); } fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u16, pixel_h: u16) void { core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = @intCast(terminalCellPixels(pixel_w, cols, 8)), .h = @intCast(terminalCellPixels(pixel_h, rows, 16)), }, } }); } /// Translate backend-neutral source/destination pixels into Kitty's source /// crop plus cell-sized placement. Kitty can specify only one scaled axis /// without distorting the image; the terminal derives the other axis. fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, screen_rows: u16, screen_pixel_w: u16, screen_pixel_h: u16, ) ?KittyPlacement { if (comptime pardes.pdf_enabled) { if (place.native.fit == .contain) return null; const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8); const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); const body_w = std.math.mul(u32, place.w, cell_w) catch return null; const body_h = std.math.mul(u32, place.h, cell_h) catch return null; const geometry = place.native.geometry orelse pardes.image.nativeGeometry( place.iw, place.ih, body_w, body_h, place.native.fit, place.native.pan_x, place.native.pan_y, ) orelse return null; // Kitty has a top-left pixel offset but no destination bottom clip. // Its missing c/r axis is rounded up to whole terminal cells, so a // clipped fragment shorter than one row cannot be represented without // painting the following theme gap. Conservatively keep only whole // rows contained by geometry.dst and trim the source crop to the same // scale. Cached page pixels remain unchanged; an unrepresentable tail // is simply left as theme background. const safe_rows_u32 = geometry.dst.h / cell_h; if (safe_rows_u32 == 0) return null; const safe_pixel_h = safe_rows_u32 * cell_h; var safe_src_h: u32 = geometry.src.h; var declared_cols: u32 = 0; var declared_rows: u32 = 0; switch (place.native.fit) { .width => { declared_cols = @intCast(@max( @as(u64, 1), (@as(u64, geometry.dst.w) + cell_w - 1) / cell_w, )); const declared_pixel_w = @as(u64, declared_cols) * cell_w; const max_src_h: u32 = @intCast( @as(u64, safe_pixel_h) * geometry.src.w / declared_pixel_w, ); if (max_src_h == 0) return null; safe_src_h = @min(safe_src_h, max_src_h); const aspect_pixel_h: u32 = @intCast( (@as(u64, safe_src_h) * declared_pixel_w + geometry.src.w - 1) / geometry.src.w, ); declared_rows = @intCast( (@as(u64, aspect_pixel_h) + cell_h - 1) / cell_h, ); }, .height => { declared_rows = safe_rows_u32; safe_src_h = @max(@as(u32, 1), @as(u32, @intCast( @as(u64, safe_pixel_h) * geometry.src.h / geometry.dst.h, ))); safe_src_h = @min(safe_src_h, geometry.src.h); const aspect_pixel_w: u32 = @intCast( (@as(u64, geometry.src.w) * safe_pixel_h + safe_src_h - 1) / safe_src_h, ); declared_cols = @intCast( (@as(u64, aspect_pixel_w) + cell_w - 1) / cell_w, ); }, .contain => unreachable, } if (declared_rows == 0 or declared_rows > safe_rows_u32) return null; // Every Kitty protocol field is u16. Reject an attachment which the // wire format cannot represent instead of truncating it. const src_x = std.math.cast(u16, geometry.src.x) orelse return null; const src_y = std.math.cast(u16, geometry.src.y) orelse return null; const src_w = std.math.cast(u16, geometry.src.w) orelse return null; const src_h = std.math.cast(u16, safe_src_h) orelse return null; const cell_x = std.math.cast(u16, geometry.dst.x / cell_w) orelse return null; const cell_y = std.math.cast(u16, geometry.dst.y / cell_h) orelse return null; if (cell_x >= place.w or cell_y >= place.h) return null; const pixel_x = std.math.cast(u16, geometry.dst.x % cell_w) orelse return null; const pixel_y = std.math.cast(u16, geometry.dst.y % cell_h) orelse return null; const cell_cols = std.math.cast(u16, @min( @as(u32, place.w - cell_x), (@as(u64, pixel_x) + @as(u64, declared_cols) * cell_w + cell_w - 1) / cell_w, )) orelse return null; const cell_rows = std.math.cast(u16, @min( @as(u32, place.h - cell_y), (@as(u64, pixel_y) + declared_rows * cell_h + cell_h - 1) / cell_h, )) orelse return null; if (cell_cols == 0 or cell_rows == 0) return null; return .{ .cell_x = cell_x, .cell_y = cell_y, .cell_cols = cell_cols, .cell_rows = cell_rows, .options = .{ .clip_region = .{ .x = src_x, .y = src_y, .width = src_w, .height = src_h }, .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = pixel_x, .y = pixel_y } else null, .size = switch (place.native.fit) { .width => .{ .cols = std.math.cast(u16, declared_cols) orelse return null }, .height => .{ .rows = std.math.cast(u16, declared_rows) orelse return null }, .contain => unreachable, }, }, }; } else { return null; } } test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { if (comptime !pardes.pdf_enabled) return; const base = pardes.ImagePlace{ .pane = 0, .serial = 1, .native = .{ .revision = 1, .page = 0, .fit = .width, .geometry = .{ .src = .{ .w = 96, .h = 10 }, .dst = .{ .y = 195, .w = 304, .h = 29 }, }, }, .x = 0, .y = 0, .w = 38, .h = 14, .rgba = &.{}, .iw = 96, .ih = 64, }; const width = kittyPlacement(base, 80, 16, 640, 256) orelse return error.MissingSafeKittyWidthFragment; const width_clip = width.options.clip_region.?; const width_size = width.options.size.?; try std.testing.expectEqual(@as(u16, 5), width_clip.height.?); try std.testing.expectEqual(@as(u16, 38), width_size.cols.?); try std.testing.expectEqual(@as(u16, 3), width.options.pixel_offset.?.y); const aspect_pixels = (@as(u32, width_size.cols.?) * 8 * width_clip.height.? + width_clip.width.? - 1) / width_clip.width.?; const inferred_rows = (aspect_pixels + 15) / 16; try std.testing.expect(inferred_rows * 16 <= base.native.geometry.?.dst.h); var height = base; height.native.fit = .height; height.native.geometry.?.src.h = 9; const height_fragment = kittyPlacement(height, 80, 16, 640, 256) orelse return error.MissingSafeKittyHeightFragment; try std.testing.expectEqual(@as(u16, 1), height_fragment.options.size.?.rows.?); try std.testing.expect(@as(u32, height_fragment.options.size.?.rows.?) * 16 <= height.native.geometry.?.dst.h); // There is no honest APC for less than one physical row: omitting it is // preferable to painting three pixels of the following theme gap. height.native.geometry.?.dst.h = 13; try std.testing.expect(kittyPlacement(height, 80, 16, 640, 256) == null); } test "host watch closes initial race and reloads rename-over PDF while idle" { if (comptime !file_watch.supported or !pardes.pdf_enabled) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const original = try pardes.pdf.makeOutlineTestPdf(gpa); defer gpa.free(original); const replacement = try pardes.pdf.makeNoOutlineTestPdf(gpa); defer gpa.free(replacement); try tmp.dir.writeFile(io, .{ .sub_path = "live.pdf", .data = original }); // Prepare both editor-style temporary inodes before marking the directory // so the only post-arm wake below is the second rename. try tmp.dir.writeFile(io, .{ .sub_path = "initial.pdf", .data = replacement }); try tmp.dir.writeFile(io, .{ .sub_path = "live-replacement.pdf", .data = original }); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/live.pdf", .{tmp.sub_path}); const fd = file_watch.init(true); if (fd < 0) return error.WatchInitFailed; defer _ = libc.close(fd); var watches: file_watch.Table = @splat(null); defer for (0..pardes.MAX_PANES) |id| file_watch.watchPane( fd, &watches, @intCast(id), null, 0, .{ .text = 0 }, ); const core = try pardes.Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 }); defer core.deinit(); try std.testing.expectEqual(@as(usize, 3), core.panes[0].?.pdf.?.page_count); // The core opened the three-page inode, but the host has not drained its // watch effect yet. Replace it now: install-then-reconcile must discover // the one-page document even though no source existed for this first edge. try tmp.dir.rename("initial.pdf", tmp.dir, "live.pdf", io); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == 0 and watch.on) { armed = true; try std.testing.expect(!file_watch.applyEffect(core, io, gpa, fd, &watches, 0, true)); }, else => {}, }; try std.testing.expect(armed and watches[0] != null); try std.testing.expectEqual(@as(usize, 1), core.panes[0].?.pdf.?.page_count); try tmp.dir.rename("live-replacement.pdf", tmp.dir, "live.pdf", io); try std.testing.expect(file_watch.drain(fd)); // This is the same pass the watcher thread schedules; no key, mouse, or // synthetic core file_changed event participates in the transaction. try std.testing.expect(!file_watch.reloadChanged(core, io, gpa, &watches)); const pane = core.panes[0].?; try std.testing.expectEqual(@as(usize, 3), pane.pdf.?.page_count); const disk_identity = try file_watch.identify(io, path); switch (watches[0].?.generation) { .pdf => |accepted| try std.testing.expect(accepted != null and accepted.?.eql(disk_identity)), .text => return error.PdfWatchStoredTextGeneration, } try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "reloaded") != null); core.native_images = true; var frame = std.heap.ArenaAllocator.init(gpa); defer frame.deinit(); const surface = try core.render(frame.allocator()); try std.testing.expect(surface.nimages > 0); try std.testing.expectEqual(@as(u32, 0), surface.images[0].?.native.page); } fn kittyImageRepresentable(place: pardes.ImagePlace) bool { return place.iw > 0 and place.ih > 0 and place.iw <= std.math.maxInt(u16) and place.ih <= std.math.maxInt(u16); } fn surfaceHasKittyKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (comptime pardes.pdf_enabled) if (!kittyImageRepresentable(place)) continue; if (place.cacheKey().eql(key)) return true; } return false; } const PdfWheelTarget = struct { pane: usize, page: usize }; /// A native PDF's page geometry is invalid between `setPdfPage` and the next /// render. Remember the page under a vertical wheel press so the input batch /// can stop exactly when that press crosses a page boundary. The following /// queued wheel report then sees the newly rastered page instead of treating /// missing geometry as another page-wise fallback. fn nativePdfWheelTarget(core: *const pardes.Pardes, mouse: vaxis.Mouse) ?PdfWheelTarget { if (comptime !pardes.pdf_enabled) return null; if (!core.native_images or mouse.type != .press or (mouse.button != .wheel_up and mouse.button != .wheel_down) or mouse.col < 0 or mouse.row < 0) return null; const col: u16 = @intCast(mouse.col); const row: u16 = @intCast(mouse.row); for (core.panes, 0..) |slot, id| { const pane = slot orelse continue; const rect = core.rects[id]; if (col < rect.x or col >= rect.x + rect.w or row < rect.y or row >= rect.y + rect.h) continue; const page = pane.pdfPage() orelse return null; return .{ .pane = id, .page = page }; } return null; } /// `attach` is `--attach[=]`: empty means "the session there is" (see /// `detached_client.resolve`). It is a parameter rather than an `Options` field /// because it says nothing to the core — this process does not have one when /// it is set. pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !void { const io = init.io; const gpa = init.gpa; // `--attach` only, and registered HERE — before the terminal is opened — // for the LIFO: it must run after every deferred restore below. Why a // frontend stopped is discovered deep inside the loop while the alt screen // is still up, and anything written there is erased by the switch back to // the main screen, which is the one screen a user would look at. var attach_end: AttachEnd = .none; defer attach_end.report(io); // ...and resolved before the terminal too, for the same reason turned the // other way: "no session called work" is a launch that never started, and // flashing the alt screen up and straight back down to say so is worse // than never entering it. Only the QUESTION is asked here, and asked // through client.zig because the SDL frontend asks the identical one: // `detached_client.attempt` asks it again with the socket in hand, and a // session that ends between the two answers is a `.no_session` from there // rather than a disagreement between two spellings of the same scan. var name_buf: [detached_server.path_max]u8 = undefined; var attach_name: []const u8 = &.{}; if (attach) |requested| switch (detached_client.resolve(&name_buf, requested)) { .name => |resolved| attach_name = resolved, // Two ends for the union's one arm, because the advice differs: a name // that resolved to nothing is a typo to correct, and no name at all is // a session to start. .none => { attach_end = if (requested.len != 0) .{ .no_session = requested } else .nothing_detached; return; }, .ambiguous => |found| { attach_end = .{ .ambiguous = found }; return; }, }; // 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); // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a // flood of key presses: plausible-looking in insert mode, and in normal // mode every pasted character runs as a command. With it the terminal // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. // No defer to switch it back off, for the same reason the mouse modes // above have none: setBracketedPaste records state.bracketed_paste, and // vaxis's resetState — reached from the `defer vx.deinit` above, while the // tty is still open — sends the disable off that flag. try vx.setBracketedPaste(tty.writer(), true); // `--attach`: this process has a terminal and NO core. Everything above is // the terminal, which an attached frontend needs exactly as much as a whole // session does; everything below is the core, which lives in the detached // process. The branch is here so both leave by the same door — an attach // has to restore cooked mode, the main screen and the mouse the way an // ordinary exit does, and sharing the deferred teardown is the only way to // guarantee that instead of asserting it. if (attach != null) { attach_end = attachSession(init, attach_name, &tty, &vx); return; } // Everything below is the TERMINAL's, shared by the two loops that can draw // on it: the local session's and, after an `Attach`, an attached one's. The // core and everything that only a core needs is `localSession`'s. var kitty_handles = std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image).init(gpa); defer { clearNativeImages(&kitty_handles, &vx, &tty); kitty_handles.deinit(); } var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var loop: Loop = .init(io, &tty, &vx); // How a connected client leaves `localSession`, and the whole of the // handover: it is set only once `detached_client.attempt` has come back // GREETED, so every way of failing to attach leaves the local session // running with this still null. By the time `localSession` returns non-null // its scope has ended, which means every pane shell, watch, worker and // mount of the local session is already away — the teardown is a scope // exit rather than a second copy of the same defers. var attached: ?detached_client.Client = null; // The loop is STARTED inside `localSession`, because the initial forkpty // has to happen before any thread of ours exists, and stopped by whichever // loop was the last to use it: `localSession` itself when it is exiting for // good, and this defer when it handed the terminal on. Registered before // the call so LIFO puts the drain after `loop.stop()` — vaxis's reader must // be joined before the queue is emptied, or a late post lands in a queue // nobody drains again and its bytes leak. defer if (attached != null) { loop.stop(); drainAttachedQueue(&loop, gpa); }; try localSession(init, opts, &tty, &vx, &loop, &kitty_handles, &paste_buf, &attached); if (attached) |*client| { // `detach` and not `deinit`: seven bytes that turn "the peer vanished" // into "the peer left" in the session's log. defer client.detach(); var a: Attach = .{ .gpa = gpa, .client = client, .loop = &loop, .vx = &vx, .tty = &tty, // The `Shell`'s own paste buffer. Nothing is in flight in it: a // bracketed burst cannot span the switch, because the builtin that // caused the switch was a keystroke, and every `paste_start` clears // it before it fills. .paste_buf = &paste_buf, // `caps_pending` deliberately keeps its default rather than // inheriting the local session's: `enableDetectedFeatures` is // idempotent mode-setting, and the `queueRefresh` it pairs with is // wanted anyway on a screen that just changed which core draws it. // `session` keeps its empty default for a reason worth stating: the // name the `Attach` word carried lived in the core's own // `attach_buf`, and that core is deinited by the time this runs. A // later `Detach` therefore says "that session" rather than naming // it, which is also all a bare `Attach` ever said. }; attach_end = attachLoop(&a); } } /// The session that lives in THIS process: the core, its pane shells, its /// watches, its acme filesystem, its workers and the loop that pumps them. A /// function of its own rather than the tail of `run` because that makes its /// teardown a SCOPE EXIT instead of a second copy of the same nine defers — /// and the `Attach` builtin needs exactly that teardown, in exactly that LIFO /// order, before an attached loop may draw on the same terminal. The hand-copy /// it replaces had 29 lines identical to these defers and stated its ordering /// contract in prose, so nothing but a reader could enforce it. /// /// The terminal itself is NOT here: `tty`, `vx`, the alt screen, the `Loop`, /// the paste buffer and the kitty placements outlive this scope because the /// attached loop keeps drawing on them. fn localSession( init: std.process.Init, opts: pardes.Options, tty: *vaxis.Tty, vx: *vaxis.Vaxis, loop: *Loop, kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), paste_buf: *std.Io.Writer.Allocating, attached: *?detached_client.Client, ) !void { const io = init.io; const gpa = init.gpa; const allocs = pardes.allocators.init(gpa); defer pardes.allocators.deinit(); var options = opts; options.image_allocator = allocs.image; options.pdf_allocator = allocs.pdf; options.tree_sitter_allocator = allocs.tree_sitter; // the 16 MiB static buffer behind every per-frame Surface options.frame_allocator = allocs.frame; pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); pardes.syntax.start(allocs.tree_sitter); defer { pardes.image.stop(); if (comptime pardes.pdf_enabled) pardes.pdf.stop(); pardes.syntax.stop(); } var core = if (options.load_path) |lp| blk: { const bytes = try look.readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(allocs.pardes, options, bytes); } else try pardes.Pardes.init(allocs.pardes, options); defer core.deinit(); // PATH, the bash banner and the prompt rc files, in the one order that // works. Children borrow only these stable in-struct path buffers. var prompt_rcs = shell_bin.prepareForFork(); defer prompt_rcs.deinit(); var frame_arena: std.heap.ArenaAllocator = .init(allocs.frame); defer frame_arena.deinit(); // `--fs`: mount before the initial spawns, because those shells are the // ones that need PARDES_FS in their environment, and before the first // frame, because a script racing startup must find panes that are already // there. Also before any thread of ours exists — the mount forks the // setuid fusermount3 helper, and forking from a multithreaded process is // the hazard this whole region is ordered around. Null covers both "no // --fs" and "--fs but the mount failed"; the second is reported on a // message row inside `start` and the session runs on regardless. // // The teardown answers every held request, aborts the connection, // unmounts and removes `/`. The PARENT (`.../pardes`) stays, // like nested.zig's socket directory: another session may be living in it, // and rmdir of a shared directory is not ours to attempt. var fs = fs_service.start(gpa, core); // Covers the error paths and the handover; the ordinary exit unmounts at // the END OF THE LOOP instead, see there. defer if (fs) |f| f.deinit(); var sh: Shell = .{ .io = io, .gpa = gpa, .lsp_gpa = allocs.lsp, .core = core, .prompt_rcs = &prompt_rcs, .loop = loop, .vx = vx, .tty = tty, .kitty = kitty_handles, .frame = &frame_arena, .paste_buf = paste_buf, // One watcher 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. `false`: this host parks a thread // in it rather than polling it. -1 where there is no watcher to make: // watchPane goes quiet and the core simply never gets a file_changed. .inotify_fd = file_watch.init(false), .fs = fs, }; // The protocol client's reader threads narrate server state through this // sink from the moment it is set; posting is safe because the loop queue // outlives them all — and it is UNSET first thing in the defer below, // under the sink's own lock, so no reader can be mid-post when the queue // starts draining for teardown. pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { pardes.lsp.setStatusSink(null, null); // 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 (&sh.ptys) |*slot| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); // THE ONE DELTA BETWEEN THE TWO WAYS OUT OF THIS SCOPE, and the // reason it is a condition rather than a comment: an exit leaves // the closed master's SIGHUP to kill the shell and the kernel to // collect it, which server.zig's `harvest` calls "the one // bookkeeping cost a long-lived process pays that a frontend, // which exits, never did". A handover does not exit — this process // goes on drawing somebody else's session for hours — so a skipped // `waitpid` is a zombie per pane held for all of it. SIGKILL and // not the hangup alone because the wait has to be BOUNDED: the // master is gone, so there is nothing left for the shell to print // and no graceful exit left to give it, and SIGHUP is a signal it // may decline while SIGKILL is not. if (attached.* != null) { _ = libc.kill(pt.pid, posix.SIG.KILL); _ = libc.waitpid(pt.pid, null, 0); } 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 (sh.lsp_task) |*t| { t.cancel(io) catch {}; sh.lsp_task = null; } sh.pipe_tasks.cancelAll(io); // same contract as the pty readers: the watcher has to be off the // descriptor before it is closed. `stop` is what releases a kqueue wait // (macos); `cancel` is what interrupts the blocking read (linux). file_watch.stop(sh.inotify_fd); if (sh.watch_task) |*t| { t.cancel(io) catch {}; sh.watch_task = null; } if (sh.inotify_fd >= 0) { _ = libc.close(sh.inotify_fd); sh.inotify_fd = -1; } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .command => |line| gpa.free(line), .paste => |b| gpa.free(@constCast(b)), .lsp_done => |d| allocs.lsp.free(d.rows), .lsp_status => |text| allocs.lsp.free(text), .pipe_done => |response_value| { var response = response_value; response.deinit(gpa); }, else => {}, }; } const host = sh.host(); // The socket a pardes launched inside this one connects to (nested.zig). // Declared AFTER the drain above so its teardown runs BEFORE it — the // listener thread must be out of the way before the queue is emptied. // --nested opted out of the whole mechanism, including being an outer // instance; so does any failure to bind, and then children simply open // their own session. const sock_fd: c_int = if (options.nested) -1 else nested.listen(); defer nested.unlisten(sock_fd); // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. `pump` installs // the host on every pass; this drain runs outside it, so install it here. core.host = host; while (core.nextEffect()) |effect| core.perform(effect); try loop.start(); // ...and stopped here only when this scope is the last user of the // terminal. A handover leaves vaxis's reader running for the attached loop, // which is drawing on the same tty a moment later; `run` stops it then. defer if (attached.* == null) 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(); // ...and the nested-instance listener, detached for the same reason: a // blocking accept(2) never returns either, so an io.concurrent task would // hang the teardown that joins it. if (sock_fd >= 0) (try std.Thread.spawn(.{}, lookServer, .{ gpa, sock_fd, loop })).detach(); // ...and the /dev/fuse poller, which is the same kind of thread again: it // waits for POLLIN and posts, never touching the core or the descriptor's // data. Joined by `Fs.deinit` rather than detached, because unlike accept4 // it CAN be woken — fuse.zig gives it a control pipe for exactly that. fs_service.wake(fs, loop, wakeFs); // 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 pollFrame). 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()); // now threads are fine: start a reader task per pty sh.threads_ok = true; for (&sh.ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, sh.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 (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; // The core owns the loop ORDER (see Pardes.pump); the outer `while` stays // here rather than being `core.run` for one reason: Restore swaps the // whole core, and a core cannot replace itself from inside its own frame. frames: while (!core.quit) { try core.pump(host); // 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(allocs.pardes, o, bytes) catch break :blk; for (&sh.ptys, 0..) |*slot, pid| if (slot.*) |*pt| { pt.reader.cancel(io) catch {}; _ = libc.close(pt.file.handle); slot.* = null; sh.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..pardes.MAX_PANES) |wid| file_watch.watchPane( sh.inotify_fd, &sh.watches, @intCast(wid), null, 0, .{ .text = 0 }, ); _ = file_watch.applyThemeEffect(core, gpa, sh.inotify_fd, &sh.watches, 0, false, false); clearNativeImages(kitty_handles, vx, tty); nc.native_images = vx.caps.kitty_graphics; core.deinit(); core = nc; sh.core = nc; if (comptime pardes.pdf_enabled) { // A restored core did not receive the terminal's earlier // winsize event. Reapply both the grid and physical cells // before its first PDF frame so pointer/pan geometry stays // identical to placement. updateCoreTerminalSize(core, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix); } } // `Attach [name]`: hand this terminal to a detached session and stop // being a session at all. CONNECTING IS NOT BEING ATTACHED, which is // why this asks `detached_client.attempt` for a GREETED client and not // for a socket: `Client.open` writes a hello and returns, and every way // a session says no — `refuse .version` for a session built from other // bytes, `.full`, `.quitting`, or a plain `quit` from one that ended in // the same round — arrives after a successful `connect(2)`. A swap that // trusted the connect would already have SIGKILLed every pane shell, // unmounted the filesystem and freed every undo history by the time it // decoded the refusal. // // So `attached` is set only with the welcome in hand, and until it is, // NOTHING here has been touched: a failed `Attach` costs one message // row and leaves every pane, every shell and every undo history where // it was. The teardown that follows is this function's own defers, // reached by leaving its scope. if (core.takeAttach()) |req| { var attempt = detached_client.attempt(gpa, req.name, core.screen_w, core.screen_h); switch (attempt) { .greeted => |client| { attached.* = client; break :frames; }, else => { var mbuf: [256]u8 = undefined; core.setMessage(req.pane, attemptEnd(&attempt, req.name).row(&mbuf)); }, } } } // THE FILESYSTEM GOES FIRST, ahead of every deferred teardown below. // `loop.stop()` joins a reader parked in `read(2)` on the tty, so it does // not return until the next keystroke — and a session that has decided to // exit must not spend that wait holding a mount nobody is serving. A // client blocked on `/event` when the last pane is deleted through // `ctl` then gets ENOTCONN at once instead of hanging until somebody // touches the keyboard. A handover skips this and lets the deferred // unmount do it, because it does not stop the loop and so never waits. if (fs) |f| { f.deinit(); fs = null; sh.fs = null; } } /// Free every kitty placement this session put on the terminal and forget them. /// THREE callers and one reason: the pixels live in the TERMINAL, not in the /// core, so a core that is replaced (`Restore`), handed away (`Attach`) or /// simply gone (the exit) leaves placements the next frames know nothing about /// and would paint text around. The exit's own caller follows it with `deinit`; /// the two mid-session ones keep the map's capacity for the frames after. fn clearNativeImages( kitty_handles: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), vx: *vaxis.Vaxis, tty: *vaxis.Tty, ) void { var iterator = kitty_handles.valueIterator(); while (iterator.next()) |handle| vx.freeImage(tty.writer(), handle.id); kitty_handles.clearRetainingCapacity(); } /// Empty the event queue an attached loop leaves behind, AFTER `loop.stop()` /// has joined vaxis's reader. Two of its events own gpa bytes — a decoded paste /// (a bracketed burst, or an OSC 52 reply to the session's `read_clipboard`) /// and a nested-instance command line — and the thread that posts the second /// cannot be joined at all, so the drain is not optional on either path out. fn drainAttachedQueue(loop: *Loop, gpa: std.mem.Allocator) void { while (loop.tryEvent() catch null) |ev| switch (ev) { .paste => |b| gpa.free(@constCast(b)), .command => |line| gpa.free(line), else => {}, }; } /// Everything the terminal shell owns and the core cannot: the ptys, the /// inotify table, the worker futures, and the one thread allowed to touch /// `tty.writer()`. This struct IS the `Host.ctx`. const Shell = struct { io: std.Io, gpa: std.mem.Allocator, lsp_gpa: std.mem.Allocator, /// live core; Restore replaces it (see run) core: *pardes.Pardes, prompt_rcs: *const shell_bin.PromptRcs, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, kitty: *std.AutoHashMap(pardes.ImageCacheKey, vaxis.Image), frame: *std.heap.ArenaAllocator, /// Where a bracketed paste is assembled. It has to outlive one drain pass: /// the burst arrives over as many passes as the terminal takes to write /// it, and the markers are the only thing that says where it ends. paste_buf: *std.Io.Writer.Allocating, inotify_fd: c_int, /// acme's control filesystem for this session, or null when `--fs` was not /// given (or its mount failed). Owned by `run`, which mounts it before the /// first fork and tears it down on every path out. fs: ?*fuse.Fs = null, 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) gens: [pardes.MAX_PANES]u32 = @splat(0), watches: file_watch.Table = @splat(null), watch_task: ?std.Io.Future(anyerror!void) = null, /// the single in-flight language query (see the lsp method) lsp_task: ?std.Io.Future(anyerror!void) = null, /// Selection filters may overlap: a second submit supersedes the first in /// core without synchronously canceling a possibly slow shell command. pipe_tasks: PipeTasks = .{}, /// false during the pre-loop drain: no worker exists to answer to yet threads_ok: bool = false, caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, /// the tracks the frame in flight was composed from tracks: []const pardes.panel_animation.Track = &.{}, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); } fn host(s: *Shell) host_api.Host { return .{ .ctx = s, .vtable = if (comptime pardes.isolated) &isolated_vtable else &vtable }; } /// An ISOLATED build (`zig build run-isolated`): the terminal this draws on /// and the keys it reads, and nothing else. Every other method stays null, /// so the core answers it itself — the embedded source filesystem, the /// in-process clipboard, silent ptys. Nothing is forked, nothing on disk is /// opened or written, and no clipboard leaves the process. The option is /// comptime, so the other vtable is not even built into that binary. const isolated_vtable: host_api.Host.VTable = .{ .pull_wait_input = waitInput, .push_present = present, .push_post_present = postPresent, }; const vtable: host_api.Host.VTable = .{ .pull_wait_input = waitInput, .push_present = present, .push_post_present = postPresent, .push_poll_frame = pollFrame, .push_spawn = spawn, .push_pty_write = ptyWrite, .push_pty_resize = ptyResize, .push_pty_signal = ptySignal, .pull_tty_taken = ttyTaken, .push_write_file = writeFile, .push_write_dump = writeDump, .push_watch_file = watchFile, .push_watch_theme = watchTheme, .push_dump_themes = dumpThemes, .push_set_clipboard = setClipboard, .pull_read_clipboard = readClipboard, .push_open_link = openLink, .pull_lsp = lsp, .pull_pipe = pipe, .push_fs_reply = fsReply, }; // ---- input ------------------------------------------------------------ /// Block for one event, then apply the whole pending batch. Everything /// goes through `core.update` rather than `postEvent`: a pty chunk borrows /// its bytes for the call, and mixing queued with immediate delivery would /// reorder a keystroke against the output it caused. fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; if (timeout_ms == 0) { // A failed read is a DEAD event source — the reader is gone and no // event can ever arrive again, so nothing could set `quit` and the // outer `while (!core.quit)` would spin at full speed. End the // session, exactly as the pre-vtable `try loop.nextEvent()` did. const first = s.loop.nextEvent() catch { s.core.quit = true; return s.reloadWatched(); }; batch = 1; if (s.apply(first)) return s.reloadWatched(); } else { // Animating: the frame clock IS the wait, and the `.tick` that // spends it is ours to post — `pump` cannot, because only this // host knows when a real frame interval has passed. Anything that // queues during the short sleep is drained below, in this pass. std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; _ = s.apply(.tick); batch = 1; } // 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. A // paste in flight keeps draining WITHOUT rendering: a hundred thousand // pasted characters are one edit. That cannot spin — the drain still // ends the moment the queue runs dry. while (s.in_paste or batch < 64) { const ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; if (s.apply(ev)) break; } s.reloadWatched(); } /// Apply one vaxis event. Returns true when the batch must end here: the /// session is over, a pty chunk wants its own frame so progress paints as /// it arrives, or a native PDF page crossing needs geometry this render /// has not produced yet. fn apply(s: *Shell, event: @TypeOf(Command.value)) bool { const core = s.core; const tz_event = tracy.zone(@src(), "event"); defer tz_event.end(); switch (event) { .nop => {}, .tick => core.update(.tick), .quit => { core.quit = true; return true; }, .focus_in => {}, .focus_out => core.update(.pointer_leave), .winsize => |ws| { s.vx.resize(s.gpa, s.tty.writer(), ws) catch {}; if (comptime pardes.pdf_enabled) updateCoreTerminalSize(core, ws.cols, ws.rows, ws.x_pixel, ws.y_pixel) else core.update(.{ .resize = .{ .cols = ws.cols, .rows = ws.rows } }); }, .pty_read => |pr| { core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } }); s.gpa.free(pr.bytes); return true; }, .pty_eof => |e| if (s.gens[e.id] == e.gen) { if (s.ptys[e.id]) |*pt| { pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks _ = libc.close(pt.file.handle); s.ptys[e.id] = null; } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, .key_press => |key| if (s.in_paste) { const bytes = pasteBytes(key); const room = max_paste_bytes -| s.paste_buf.written().len; s.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; } else core.update(keyEvent(key)), .mouse => |m| { const pdf_before = nativePdfWheelTarget(core, m); if (mouseEvent(m)) |ev| core.update(ev); if (pdf_before) |before| { if (core.panes[before.pane]) |pane| { if (pane.pdfPage()) |page| { if (page != before.page) return true; } } } }, .paste => |bytes| { core.update(.{ .paste = bytes }); s.gpa.free(@constCast(bytes)); }, .paste_start => { s.in_paste = true; s.paste_buf.clearRetainingCapacity(); }, .paste_end => { s.in_paste = false; // ONE event for the whole paste — the core borrows the // bytes for the call, exactly like the OSC 52 arm above. const pasted = s.paste_buf.written(); if (pasted.len > 0) core.update(.{ .paste = pasted }); s.paste_buf.clearRetainingCapacity(); }, .command => |line| { core.update(.{ .command = line }); s.gpa.free(line); }, // 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 => s.check_files = true, // A wake and nothing more. The requests behind it are drained in // pollFrame, where the file-watch reload also happens: both want to // run once per frame with the whole batch already in, not once per // event. So this arm has nothing to do — which is the point, since // its only job was ending the blocking wait above. .fs_ready => {}, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); s.lsp_gpa.free(d.rows); // the worker is finished; join it so its future does not // leak (same contract as pty_eof above) if (s.lsp_task) |*t| { t.await(s.io) catch {}; s.lsp_task = null; } }, // Server state on the transient message row — the same row, the // same `message.stamp` clock, and the same shell-side ownership a // completed save uses. The ACTIVE pane, because the state of a // server is session news, not a fact about the pane that asked. .lsp_status => |text| { var mbuf: [256]u8 = undefined; core.setMessage(core.active, message.stamp(&mbuf, "lsp", text)); s.lsp_gpa.free(text); }, .pipe_done => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, } }); response.deinit(s.gpa); s.pipe_tasks.finish(s.io, response_value.id); }, } return false; } fn reloadWatched(s: *Shell) void { if (!s.check_files) return; s.check_files = false; if (file_watch.reloadChanged(s.core, s.io, s.gpa, &s.watches)) s.loop.postEvent(.files_changed) catch {}; } // ---- the frame --------------------------------------------------------- fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); // acme's filesystem, answered here for the same reason the file-watch // reload is (see reloadWatched): one batch per frame, not one frame per // request. First in the pass, so an edit a script just made through // `body` is in the surface this frame composes rather than the next. if (s.fs) |f| if (fs_service.drain(f.transport(), s.core).pending) { // The batch hit its cap with requests still pending, and no ack has // gone to the poll thread — so nothing else will wake us. Re-arm // the loop ourselves. tryPostEvent, not postEvent: this runs on the // only thread that drains the queue, so blocking on a full one // would deadlock, and a full queue already holds a wake. _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; // live cwd for tags/look: cheap per-pane lookup, per frame. Whether the // pane's tty still belongs to the prompt we forked is NOT polled here — // it is a walk through /proc and nothing draws it, so the core pulls it // through tty_taken instead, at the Exec that cares. for (&s.ptys, 0..) |*slot, id| if (slot.*) |pt| { var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |cwd| s.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 (s.caps_pending and s.vx.queries_done.load(.unordered)) { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; // The core was constructed before the asynchronous handshake. // Advertise native pixels only now, after every reply preceding // DA1 has updated the capability set. s.core.native_images = s.vx.caps.kitty_graphics; s.vx.queueRefresh(); } } /// The canonical surface -> vaxis, cell for cell, with no interpretation. fn present(ctx: ?*anyopaque, canonical: *const pardes.Surface) void { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); _ = s.frame.reset(.retain_capacity); // compose only READS the canonical surface; the mutable pointer is so // it can hand it straight back when no panel is mid-transition. const surface = panel_compositor.compose( s.frame.allocator(), @constCast(canonical), s.tracks, s.core.theme().bg, ) catch return; const tz_cells = tracy.zone(@src(), "surface->vaxis"); const win = vx.window(); paintCells(win, surface.cells, surface.cols, surface.rows); tz_cells.end(); // Pixel attachments (kitty graphics): transmit once per pixel // generation, then re-place every frame (placements aren't // persistent). The map is keyed by pane lifetime + PDF page + pixel // revision, so any number of short visible pages can coexist without // aliasing a fixed terminal cache slot. for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; // Keep the placement in Surface so the terminal-side cache stays // live, but do not pin native pixels over a panel whose cells are // currently moving through the TTY grid. if (panel_compositor.hidesAttachment(surface.panelTracks(), place.serial)) continue; if (comptime pardes.pdf_enabled) { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { defer s.gpa.free(b64); _ = enc.encode(b64, place.rgba); if (vx.transmitPreEncodedImage(s.tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null) |handle| s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id); } else |_| {} } if (s.kitty.get(key)) |cached| { if (comptime !pardes.pdf_enabled) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else { if (place.native.fit == .contain) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else if (kittyPlacement( place, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix, )) |placement| { const child = win.child(.{ .x_off = @as(i17, place.x) + placement.cell_x, .y_off = @as(i17, place.y) + placement.cell_y, .width = placement.cell_cols, .height = placement.cell_rows, }); cached.draw(child, placement.options) catch {}; } } } } // Toggling PETSCII or closing a pane removes its attachment from the // Surface. Release the terminal-side image then, not merely when that // numeric pane slot happens to be reused. while (true) { var stale: [pardes.MAX_PANES]pardes.ImageCacheKey = undefined; var stale_len: usize = 0; var image_iterator = s.kitty.iterator(); while (image_iterator.next()) |entry| { if (surfaceHasKittyKey(surface, entry.key_ptr.*)) continue; stale[stale_len] = entry.key_ptr.*; stale_len += 1; if (stale_len == stale.len) break; } for (stale[0..stale_len]) |key| if (s.kitty.fetchRemove(key)) |removed| vx.freeImage(s.tty.writer(), removed.value.id); if (stale_len < stale.len) break; } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); vx.render(s.tty.writer()) catch {}; tz_render.end(); } fn postPresent(ctx: ?*anyopaque) void { const s = of(ctx); s.core.acknowledgePanelPresentation(s.tracks); tracy.frameMark(); } // ---- pseudo-terminals --------------------------------------------------- fn spawn(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void { const s = of(ctx); // 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 (s.ptys[pane]) |*old| { old.reader.cancel(s.io) catch {}; _ = libc.close(old.file.handle); s.ptys[pane] = null; } s.gens[pane] +%= 1; var cwd_buf: [256:0]u8 = undefined; var cwd_z: ?[*:0]const u8 = null; if (cwd.len > 0 and cwd.len < cwd_buf.len) { @memcpy(cwd_buf[0..cwd.len], cwd); cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs); s.ptys[pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } }; // report the pane's starting directory back to the core (tags). The // slot needs no occupancy reset: nothing is remembered, and the next // Exec asks about the shell that is there now. var lbuf: [1024]u8 = undefined; if (look.shellCwd(child.pid, &lbuf)) |wd| s.core.setCwd(pane, wd); if (s.threads_ok) { if (s.ptys[pane]) |*pt| { pt.reader = s.io.concurrent(readPty, .{ s.io, s.gpa, pt.file, @as(usize, pane), s.gens[pane], s.loop }) catch pt.reader; } } } fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void { const s = of(ctx); if (s.ptys[pane]) |pt| _ = host_io.writeFd(pt.file.handle, bytes); } fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = of(ctx); if (s.ptys[pane]) |pt| { const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } } /// `pty/ctl`'s `sig`. A pane with no pty of ours has nothing to signal, /// which is the same silence `ptyWrite` above gives it. fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void { const s = of(ctx); if (s.ptys[pane]) |pt| look.signalTty(pt.pid, pt.file.handle, sig); } /// Is a pane's tty still the prompt we forked? Lazy by construction — it /// runs only where the core is about to type a command line, so the /proc /// walk costs nothing on an ordinary frame. A pane with no pty of ours (a /// document, a slot whose shell already died) is not a terminal a program /// can be holding. fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool { const s = of(ctx); const pt = s.ptys[pane] orelse return false; return look.ttyTaken(pt.pid, pt.file.handle); } // ---- the filesystem ----------------------------------------------------- fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void { const s = of(ctx); // SAY WHY, and tell the core it did not happen. A save that cannot be // done is the one failure this program must never swallow: `saveFailed` // puts the reason on the pane's message row and leaves the pane dirty, // so the ` *` stays and `Del` cannot quietly take the edits. host_io.writeFileBytes(path, bytes) catch |err| return s.core.saveFailed(pane, "save", err); // 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". Only when // this IS the pane's watched file — a `Save ` must not // silence a real change to the file the pane has open. if (s.core.panes[pane]) |pn| if (pn.file) |f| if (std.mem.eql(u8, f.path, path)) { if (s.watches[pane]) |*w| if (w.serial == pn.serial) switch (w.generation) { .text => w.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }, .pdf => {}, }; }; // ...and say so on the pane's message row. AFTER the write, not beside // it: every early return above is a save that did not happen and must // not be reported as one. var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "saved", path)); } fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse return; host_io.writeFileBytes(path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } fn watchFile(ctx: ?*anyopaque, pane: u8, _: []const u8, on: bool) void { const s = of(ctx); if (file_watch.applyEffect(s.core, s.io, s.gpa, s.inotify_fd, &s.watches, pane, on)) s.loop.postEvent(.files_changed) catch {}; } fn watchTheme(ctx: ?*anyopaque, generation: u32, on: bool) void { const s = of(ctx); if (file_watch.applyThemeEffect(s.core, s.gpa, s.inotify_fd, &s.watches, generation, on, s.threads_ok)) s.loop.postEvent(.files_changed) catch {}; } /// The core's answer to one filesystem request, handed straight back to the /// transport that is holding it. `bytes` was resolved by `pardes.fsPayload` /// inside `perform` and is borrowed only for this call — a body read is a /// window onto the pane's live text, so there is nothing to copy and /// nothing to free. `.again` needs no special case here: `Fs.reply` reads /// the status and re-parks the request itself. fn fsReply(ctx: ?*anyopaque, reply: *const pardes.acmefs.Reply, bytes: []const u8) void { const s = of(ctx); if (s.fs) |f| f.reply(reply, bytes); } fn dumpThemes(ctx: ?*anyopaque, pane: u8) void { const s = of(ctx); const config_dir = s.core.opts.config_dir orelse return; const out_dir = user_config.dumpThemes(s.io, s.gpa, config_dir, pardes.themes) catch |err| { s.core.reportError(pane, "dump themes", err); return; }; defer s.gpa.free(out_dir); var mbuf: [256]u8 = undefined; s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir)); } // ---- the desktop -------------------------------------------------------- // // The three effects a detached session still puts on the wire // (`wire.ServerTag` 0x10..), because each of them needs the display a // human is actually looking at rather than the machine the core runs on. // These are the `Host.ctx` shims and nothing else: the terminal work is // `copyToClipboard`/`requestClipboard` below this struct, so an attached // frontend serving `set_clipboard`/`read_clipboard` writes the same escape // sequences from the same lines. fn setClipboard(ctx: ?*anyopaque, text: []const u8) void { const s = of(ctx); copyToClipboard(s.vx, s.tty, s.gpa, text); } fn readClipboard(ctx: ?*anyopaque) void { const s = of(ctx); requestClipboard(s.vx, s.tty); } fn openLink(_: ?*anyopaque, url: []const u8) void { look.openLink(url); // desktop browser; no terminal in it, so Attach calls this one directly } // ---- work that must leave the loop -------------------------------------- fn lsp(ctx: ?*anyopaque, req: host_api.LspRequest) void { const s = of(ctx); if (!s.threads_ok) return; // pre-loop drain: nothing to answer to yet const job = lsp_host.snapshot(s.lsp_gpa, s.core, req) orelse return; // 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 (s.lsp_task) |*old| { old.cancel(s.io) catch {}; s.lsp_task = null; } s.lsp_task = s.io.concurrent(lspWorker, .{ s.lsp_gpa, job, s.loop }) catch { job.free(s.lsp_gpa); return; }; } fn pipe(ctx: ?*anyopaque, id: u32) void { const s = of(ctx); if (!s.threads_ok) return; if (s.pipe_tasks.full()) { s.core.update(.{ .pipe_resp = .{ .id = id, .success = false, .outputs = &.{} } }); return; } const view = s.core.pipeRequest(id) orelse return; const job = selection_pipe.Job.copy(s.gpa, view) catch return; const future = s.io.concurrent(pipeWorker, .{ s.io, s.gpa, job, s.loop }) catch { job.deinit(s.gpa); return; }; // `full()` was checked above, so this cannot fail; assert rather than // discard, because a silently dropped task is a future nobody joins. std.debug.assert(s.pipe_tasks.add(.{ .id = id, .future = future })); } }; /// Mirror a yank register out via OSC 52. Free functions over the terminal /// they write to rather than `Shell` methods, because the clipboard is the one /// piece of host work that survived the move into the daemon and BOTH loops in /// this file perform it: `Shell` for its own core's `Effect.set_clipboard`, and /// `Attach` for a detached session's `wire.ServerMsg.set_clipboard`. One /// escape sequence written in two places is one that drifts. fn copyToClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty, gpa: std.mem.Allocator, text: []const u8) void { if (text.len == 0) return; vx.copyToSystemClipboard(tty.writer(), text, gpa) catch {}; } /// ...and the other direction, OSC 52 read. The answer arrives on vaxis's /// reader thread as an ordinary `.paste` event and reaches whoever asked — /// the local core through `Shell`, the session through `ClientTag.event` — /// by the same path an outer bracketed paste takes; this request is the only /// wiring it needs. "The answer arrives" is the optimistic reading: a /// clipboard READ is an exfiltration primitive and terminals treat it as one /// (ghostty prompts by default, xterm ships it off, a multiplexer or ssh link /// may eat it), and a refusal looks exactly like silence. So the core's /// pending request is dropped by the next keystroke rather than pasting /// minutes late, and `SPC p` in a locked-down terminal honestly does nothing. fn requestClipboard(vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { vx.requestSystemClipboard(tty.writer()) catch {}; } /// Answer a language query off the event loop and post the rows back. The /// snapshot and the query body are `lsp_host`'s; the only part that is this /// shell's is the vaxis event the rows travel home on. fn lspWorker(allocator: std.mem.Allocator, job: *lsp_host.Job, loop: *Loop) anyerror!void { var sink: LspRowSink = .{ .allocator = allocator, .loop = loop }; lsp_host.work(allocator, job, &sink, LspRowSink.take); } const LspRowSink = struct { allocator: std.mem.Allocator, loop: *Loop, fn take(ctx: ?*anyopaque, id: u32, rows: []u8) void { const s: *LspRowSink = @ptrCast(@alignCast(ctx orelse return)); s.loop.postEvent(.{ .lsp_done = .{ .id = id, .rows = rows } }) catch s.allocator.free(rows); } }; /// The registered `lsp.setStatusSink` target, called from the protocol /// client's READER threads. Only thread-safe, NON-BLOCKING things happen /// here: a dupe with the concurrent lsp allocator and a TRY-post onto the /// loop's queue. Never the blocking post — the sink lock is held around this /// call, and a full queue plus a teardown spinning on that lock would be a /// deadlock; server state is periodic news, so a dropped line is repriced /// by the next one. fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void { const s: *Shell = @ptrCast(@alignCast(ctx orelse return)); const copy = s.lsp_gpa.dupe(u8, text) catch return; const posted = s.loop.tryPostEvent(.{ .lsp_status = copy }) catch false; if (!posted) s.lsp_gpa.free(copy); } fn pipeWorker( io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, loop: *Loop, ) anyerror!void { defer job.deinit(gpa); var response = selection_pipe.runJob(gpa, io, job); loop.postEvent(.{ .pipe_done = response }) catch response.deinit(gpa); } /// 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 { // A kqueue cannot be read, so there is no std.Io.File to wrap and no // `cancel` to interrupt: this arm parks in kevent(2) and the teardown's // `file_watch.stop` is what releases it. See file_watch.wait. if (comptime builtin.os.tag != .linux) { while (file_watch.wait(fd)) loop.postEvent(.files_changed) catch break; return; } 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; } } /// Block on the nested-instance socket and hand the loop each command line a /// pardes started inside this one sends. Same shape as winchWatch: a plain /// detached thread around a call that never returns, posting into the vaxis /// loop from ordinary thread context. /// /// Nothing here is woken by teardown — close(2) does NOT release a thread /// parked in accept4 on linux — so this dies with the process, exactly as /// winchWatch dies inside sigwait. The window that leaves is one connection /// accepted between the last drain and process exit posting into a queue whose /// owner has returned; same shape and same bound as every other detached /// worker here, and a self-pipe to close it would be more machinery than the /// window is worth. fn lookServer(gpa: std.mem.Allocator, fd: c_int, loop: *Loop) void { var buf: [nested.max_line]u8 = undefined; while (nested.acceptLine(fd, &buf)) |line| { const owned = gpa.dupe(u8, line) catch continue; loop.postEvent(.{ .command = owned }) catch { gpa.free(owned); 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 {}; } } /// The /dev/fuse poller's wake, and deliberately nothing else — the thread that /// calls this has no business in the core, so all it does is end the blocking /// `nextEvent`. tryPostEvent rather than postEvent for the same reason readPty's /// final post uses it: this can fire after the loop has already been left, and a /// blocking push into a full queue nobody is draining would never return. fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; } 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, }; } /// 4 MiB ceiling, past which the tail is dropped rather than grown into. A /// paste that large is a mis-click on a file, not an edit, and the core would /// have to hold the whole of it as one undo entry. File scope rather than a /// `Shell` decl because the `--attach` frontend accumulates the same bursts /// against the same ceiling, and wire.zig cites this name as THE cap. const max_paste_bytes: usize = 4 << 20; /// One key press between the bracketed-paste markers, as the bytes it means. /// A key in there is DATA, never a command, and the two callers — the /// in-process host and the `--attach` frontend — must agree exactly, because /// what they produce is compared against what a terminal's own paste would /// have delivered. fn pasteBytes(key: vaxis.Key) []const u8 { const text = key.text orelse ""; if (text.len > 0) return text; const cp = mapKey(effCp(key)); if (cp == pardes.Key.tab) return "\t"; // vaxis gives control bytes no text at all: a line break inside a paste // reaches the ground parser as a bare CR (-> Key.enter) or, from a // terminal that does not translate them, a bare LF — which that parser // reports as ctrl+j. Nothing in here is a real keypress, so both of them // are just a newline. if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) return "\n"; // arrows, F-keys, a stray escape: noise a paste has no business carrying, // dropped rather than smuggled in. return ""; } /// ...and one ordinary key press as the core's event. Shared for the reason /// above: a keystroke must mean the same thing whether the core is in this /// process or on the other end of a socket. fn keyEvent(key: vaxis.Key) pardes.Event { return .{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }; } /// ...and one mouse report. Null for a button this vocabulary has no name for /// (vaxis reports more of them than the core has), which is a report to drop /// rather than a press to invent. fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { 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 => return null, }; return .{ .mouse = .{ .button = button, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(m.col), .row = @intCast(m.row), .ctrl = m.mods.ctrl, } }; } /// THE cell walk: canonical cells -> vaxis, cell for cell, with no /// interpretation. One walk and two callers, because a `frame` off the /// detached wire IS a `Surface`'s cells — wire.zig carries the grid and /// deliberately does not carry the two halves `Shell.present` adds around this /// (the kitty attachments, which have no encoding, and the panel transition, /// which the session composes before it sends). A second walk here would be /// two renderers for one canonical interface, and the interface is the thing /// this editor is. fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { var x: u16 = 0; while (x < cols) : (x += 1) { const cell = &cells[@as(usize, y) * cols + x]; if (cell.default) continue; win.writeCell(x, y, .{ .char = .{ .grapheme = cell.grapheme() }, .style = vaxisStyle(cell.style), }); } } } fn paintCursor(win: vaxis.Window, x: u16, y: u16, bar: bool) void { win.showCursor(x, y); // insert = beam, everything else = the terminal's default shape win.setCursorShape(if (bar) .beam else .default); } 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 }, }; } // --------------------------------------------------------------------------- // Attached: a terminal, a socket, and no core // // Reached two ways — `--attach[=]` on the command line, and the `Attach` // builtin handing a running local session's terminal to a detached one — and // identical past the connect. NOTHING below this line forks a shell, writes a // file or watches a path: the daemon owns every machine-local effect now // (src/detached/server.zig), and the three that are still on the wire // (`wire.ServerTag` 0x10..) are there because the clipboard and the browser // are the human's, not the machine's. // --------------------------------------------------------------------------- /// Why an `--attach` frontend stopped, and where its exit status comes from. /// A VALUE rather than a message printed where it is discovered: at that point /// the alt screen is still up and everything written to it is erased by the /// restore a moment later. `run` registers `report` BEFORE it opens the /// terminal, so LIFO runs it last — on a cooked main screen, which is the one /// screen a person would go looking at. const AttachEnd = union(enum) { /// not an `--attach` run at all, or the session said `quit`: exit 0 none, /// `--attach=` and nothing is listening under it no_session: []const u8, /// bare `--attach` with no session to mean... nothing_detached, /// ...or more than one, which is a choice and not ours to make ambiguous: usize, /// the session hung up on the connect and said why refused: wire.Refusal, /// the link died, or the stream stopped making sense lost: anyerror, /// the connect landed and the session hung up before its reason arrived: a /// refusal whose six bytes raced the close (server.zig `refuseFd`) rejected, /// ...and the other silence: it accepted, kept the slot, and never greeted /// us at all inside client.zig's `attempt` deadline silent, /// `Detach` in an attached frontend: THIS frontend leaves and the session /// does not, which is the whole difference between it and `.none`. The name /// is what to come back to, and empty when this frontend never knew it (an /// `Attach` builtin's bare form: the core that held the word is gone by the /// time the attached loop runs). detached: []const u8, /// The nonzero exit lives HERE for the same reason the message does: every /// teardown this process owes is a defer registered after this one, so by /// the time this runs they have all run and there is nothing left to skip. fn report(e: AttachEnd, io: std.Io) void { var buf: [512]u8 = undefined; // Set by the one ending that is not a failure. `Detach` is a word the // user typed, so leaving is success: the line goes to STDOUT and the // exit status is untouched, because there is still a session there to // come back to. tmux's `[detached]` line is the same sentence for the // same reason. var ok = false; const text: []const u8 = switch (e) { .none => return, .detached => |name| blk: { ok = true; break :blk if (name.len != 0) std.fmt.bufPrint( &buf, "pardes: detached from '{s}', which is still running — come back with `pardes --attach={s}`\n", .{ name, name }, ) catch "pardes: detached; that session is still running\n" else "pardes: detached; that session is still running — come back with `pardes --attach`\n"; }, .no_session => |name| std.fmt.bufPrint( &buf, "pardes: no detached session called '{s}' (start one with `pardes --detach={s}`)\n", .{ name, name }, ) catch "pardes: no detached session under that name\n", .nothing_detached => "pardes: no detached session is running (start one with `pardes --detach`)\n", .ambiguous => |n| std.fmt.bufPrint( &buf, "pardes: {d} detached sessions are running; say which with --attach=\n", .{n}, ) catch "pardes: several detached sessions are running; say which with --attach=\n", .refused => |why| switch (why) { .version => "pardes: that session speaks a different wire version — it is another build of pardes\n", .full => "pardes: that session already has every frontend slot taken\n", .quitting => "pardes: that session is ending\n", }, .lost => |err| std.fmt.bufPrint(&buf, "pardes: detached session lost: {t}\n", .{err}) catch "pardes: detached session lost\n", .rejected => "pardes: that session hung up on the connect — every frontend slot is taken (32), or it is shutting down. `PARDES_LOG=1` on the session names which\n", .silent => "pardes: that session accepted the connection and then never greeted it — it is wedged inside its own loop, or something else is listening at that path. `PARDES_LOG=1` on the session says which\n", }; const out = if (ok) std.Io.File.stdout() else std.Io.File.stderr(); out.writeStreamingAll(io, text) catch {}; if (!ok) std.process.exit(1); } /// The same reason on ONE pane message row, for the `Attach` builtin. It /// exists because that path does not exit: `report` writes to a cooked main /// screen on the way out of the process and can spend a clause on advice, /// while this shares a row with a filename in a session that goes on /// running. Same vocabulary, no `pardes:` prefix and no newline. fn row(e: AttachEnd, buf: []u8) []const u8 { return switch (e) { // `report` reads `.none` as "exit 0, say nothing", and a message // row only ever shows a failure — but a session that says `quit` // before it greets is the one way this arm could be reached, and // that is what it says. .none => "attach: that session ended", .no_session => |name| std.fmt.bufPrint(buf, "attach: no session '{s}'", .{name}) catch "attach: no session under that name", .nothing_detached => "attach: no detached session is running", .ambiguous => |n| std.fmt.bufPrint(buf, "attach: {d} sessions running, name one", .{n}) catch "attach: several sessions running, name one", .refused => |why| switch (why) { .version => "attach: that session is another build of pardes", .full => "attach: that session has every frontend slot taken", .quitting => "attach: that session is ending", }, .lost => |err| std.fmt.bufPrint(buf, "attach: {t}", .{err}) catch "attach: link lost", .rejected => "attach: that session hung up on the connect", .silent => "attach: that session accepted and never greeted", // Never asked for: `attemptEnd` is the only caller and a connect // that has not happened yet cannot have been detached from. Worded // rather than left to an `else`, so the arm somebody adds next // still has to be thought about. .detached => "attach: detached from that session", }; } }; /// `--attach[=]` and the `Attach` builtin: the frontend half of a /// detached session. This process owns a terminal and a socket; the `Pardes` /// is in the session process (src/detached/). The whole job is client.zig's /// two sentences — send the input it collects, draw the frames it is sent — /// and since the daemon took its own IO back there is nothing else in it. /// /// THAT DELETION IS THE POINT. A frontend used to serve `spawn`, `pty_write`, /// `pty_resize`, `write_file`, `write_dump`, `watch_file` and `dump_themes` /// off the wire, which put every pane's shell in whichever frontend happened /// to fork it and stopped that pane's output the moment that frontend left — /// a daemon whose whole promise is outliving frontends killed your shells. A /// unix socket means the two ends share a machine, so the daemon forks and /// writes and watches for itself (src/host_io.zig, src/file_watch.zig) and /// `wire.ServerTag` keeps exactly three effects, 0x10..: the clipboard both /// ways and the browser, because each of those needs the display a human is /// actually looking at. /// /// It is NOT a `Shell`, and the difference is not size. `Shell` IS the /// `Host.ctx` of a core in THIS process, and its methods reach into that core /// on nearly every line — a pane's message row, `acknowledgeShell`, `setCwd`, /// a watch generation taken off the pane's live text. With no core those are /// not cheaper versions of the same work, they are absent. What the two /// genuinely share is shared: the cell walk, the key and mouse vocabularies, /// the paste ceiling, `copyToClipboard`, `requestClipboard`. const Attach = struct { gpa: std.mem.Allocator, client: *detached_client.Client, loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_buf: *std.Io.Writer.Allocating, /// The session this frontend asked for, borrowed for the loop's lifetime /// and only so `Detach` can name what to come back to. Empty when it is not /// knowable here — see `AttachEnd.detached`. session: []const u8 = &.{}, caps_pending: bool = true, in_paste: bool = false, /// A frame landed. Painted once at the end of the round rather than where /// it arrives: several can be decoded out of one poll and only the last of /// them is on the screen. dirty: bool = false, /// One event onto the wire. Non-null ends this frontend. fn send(a: *Attach, ev: pardes.Event) ?AttachEnd { a.client.send(.{ .event = ev }) catch |err| switch (err) { // A message this protocol cannot carry, which is not a link that // has died: `putSlice32` refuses past `wire.max_payload` (16 MiB). // One event still reaches it now that the watched files are the // daemon's — an OSC 52 clipboard reply, whose size is whatever the // terminal handed vaxis and which nothing in this file bounds // (`max_paste_bytes` bounds the bracketed-paste assembly, not a // decoded reply). Dropping it costs one paste; treating it as a // hangup would cost the session. error.Overlong, error.NoSpace => return null, else => return .{ .lost = err }, }; return null; } /// One decoded message from the session. `wire.ServerMsg` has eight arms /// and so has this switch — no catch-all, so a protocol that grows a ninth /// stops compiling here rather than quietly ignoring it. fn handle(a: *Attach, msg: wire.ServerMsg) ?AttachEnd { switch (msg) { // Already applied to the client's slot and geometry; the full frame // the session promises a fresh attach is the next thing to arrive. .welcome => {}, .refuse => |why| return .{ .refused = why }, // Applied too — `grid` and `cursor` are current by the time this // returns, so all that is left is to say the screen moved. .frame => a.dirty = true, .quit => return .none, // ...and its sibling, which is the same exit for the opposite // reason: `quit` is the session ending under every frontend, and // `Detach` is THIS frontend leaving one that carries on. The // session keeps its panes, its shells and its other frontends, so // there is nothing to report as a failure and something to come // back to — see `AttachEnd.detached`. .detach => return .{ .detached = a.session }, // The three that are left, served by the same lines the local // `Shell` runs for its own core's effects: this terminal's OSC 52 // pair and this desktop's browser. .set_clipboard => |text| copyToClipboard(a.vx, a.tty, a.gpa, text), // The answer is not a reply message: it comes back as an ordinary // `Event.paste` through the `.paste` arm of `apply`, like any other // input, which is the same asynchronous shape `pull_read_clipboard` // has in-process. .read_clipboard => requestClipboard(a.vx, a.tty), .open_link => |url| look.openLink(url), } return null; } /// One vaxis event, translated onto the wire. Non-null ends the loop. fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { // Nothing posts these here, and each absence has a reason. `tick` // is `Shell.waitInput`'s animation clock, and an animation runs // where the core is — the session sleeps on its own frame interval // (server.zig `nap`) and the frames simply arrive. `fs_ready` // belongs to `--fs`, which lives with the core. `lsp_done` and // `pipe_done` answer work the core dispatches, and it dispatches it // there; `lsp_status` narrates servers whose sink the local loop // UNSET in its teardown before this loop started, and the queue // was drained after that, so none is in flight. `pty_read`, // `pty_eof` and `files_changed` are the ones that MOVED: the // daemon forks the pane shells and holds the inotify instance // now, so the only descriptors this process reads are its // terminal and one socket. An in-place switch (`Attach` in a // local session) cancels its readers and its watcher and drains // this queue before the attached loop starts, so not even a late // post from the session it just left arrives here. .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), .winsize => |ws| { a.vx.resize(a.gpa, a.tty.writer(), ws) catch {}; // Repaint from the frame already in hand: vaxis has just thrown // its shadow grid away, and the SESSION grid may not move at // all — it is the smallest common one and another frontend may // be the small one (client.zig GEOMETRY). a.dirty = true; a.client.resize(ws.cols, ws.rows) catch |err| return .{ .lost = err }; }, .key_press => |key| if (a.in_paste) { const bytes = pasteBytes(key); const room = max_paste_bytes -| a.paste_buf.written().len; a.paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; } else return a.send(keyEvent(key)), .mouse => |m| if (mouseEvent(m)) |ev| return a.send(ev), .paste => |bytes| { defer a.gpa.free(@constCast(bytes)); return a.send(.{ .paste = bytes }); }, .paste_start => { a.in_paste = true; a.paste_buf.clearRetainingCapacity(); }, .paste_end => { a.in_paste = false; defer a.paste_buf.clearRetainingCapacity(); // ONE message for the whole paste, exactly as the in-process // host makes it one `update`. const pasted = a.paste_buf.written(); if (pasted.len > 0) return a.send(.{ .paste = pasted }); }, // A pardes launched inside a pane shell hands its file to the // nearest pardes ANCESTOR (nested.zig `outer`), and now that the // daemon forks those shells that ancestor is the daemon — which is // why `attachSession` binds no listener at all. The one line that // still reaches this arm is a switch racing itself: a local session // whose own child wrote to `localSession`'s listener in the moment // before `Attach` gave the terminal away, on a thread that is // detached and so cannot be joined ahead of the queue drain. It // goes over the wire, which is what `ClientTag.command` is for. .command => |line| { defer a.gpa.free(line); return a.send(.{ .command = line }); }, } return null; } /// The capability handshake, resolved on the loop exactly as /// `Shell.pollFrame` resolves it and for its reason: the replies land on /// vaxis's reader thread, and this is the only thread allowed to write to /// the tty. No `native_images` here — this wire carries no attachments. fn enableCaps(a: *Attach) void { if (!a.caps_pending or !a.vx.queries_done.load(.unordered)) return; a.caps_pending = false; a.vx.enableDetectedFeatures(a.tty.writer()) catch {}; // 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. a.vx.queueRefresh(); a.dirty = true; } fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); // The session grid can be smaller than this window; `paintCells` clears // first, so the surplus is the terminal's own default cell rather than // whatever was there a frame ago. paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } }; /// One `detached_client.Attempt` that did NOT come back with a client, in this /// file's own vocabulary. `requested` is what the user actually typed, because /// the union has a single `no_session` where this file has two ends for it: a /// name that resolved to nothing is a typo to correct, and no name at all is a /// session to start. fn attemptEnd(a: *const detached_client.Attempt, requested: []const u8) AttachEnd { return switch (a.*) { // Both callers take the client out of the `.greeted` arm themselves, so // this is only ever asked about a failure; `.none` is what "nothing to // report" is spelled as everywhere else in this union. .greeted => .none, .no_session => if (requested.len != 0) .{ .no_session = requested } else .nothing_detached, .ambiguous => |found| .{ .ambiguous = found }, .refused => |why| .{ .refused = why }, .silent => .silent, // A hangup with no reason decoded is what `rejected` was written for: // server.zig's `refuseFd` writes six bytes and closes in the same pass, // so the close can beat the reason onto the socket. client.zig's `give` // already prefers a refusal it did decode, so an `error.Closed` that // reaches here is that race and nothing else. .lost => |err| if (err == error.Closed) .rejected else .{ .lost = err }, }; } /// The attached loop: two event sources, one screen, no core. A function of its /// own because there are two ways to become attached and only one loop — /// `--attach` on the command line (`attachSession`, which opens the terminal /// for it) and the `Attach` builtin (see `localSession`, whose terminal already /// had one) — and past the connect the two are indistinguishable. Every thread /// it needs (vaxis's reader, the SIGWINCH sigwait) is the caller's to have /// started, which is the whole difference between the two entries. fn attachLoop(a: *Attach) AttachEnd { while (true) { const link = a.client.wait(detached_client.poll_ms); // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in // the same call that read the last bytes, and the last bytes are the // session's `quit`: `fill` appends every chunk and only then sees the // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly // this reason, and honouring that means draining what arrived before // deciding the link is what ended us — otherwise an ordinary `Kill` // exits one frontend 0 (it got the quit alone) and whichever frontend // was in the same poll round nonzero, which is what the first run of // this loop actually did. while (true) { const msg = (a.client.next() catch |err| return .{ .lost = err }) orelse break; if (a.handle(msg)) |end| return end; } link catch |err| return .{ .lost = err }; // The terminal, drained the way `Shell.waitInput` drains it and for its // reason: a wheel flick is one batch rather than fifty round trips, and // a paste in flight keeps draining without a message per character. var batch: usize = 0; while (a.in_paste or batch < 64) { // Propagated rather than swallowed, unlike `Shell.waitInput`'s // identical drain: there the blocking `nextEvent` above it is what // notices a dead event source, and here there is no blocking read // to notice with. const ev = (a.loop.tryEvent() catch |err| return .{ .lost = err }) orelse break; batch += 1; if (a.apply(ev)) |end| return end; } a.enableCaps(); if (a.dirty) a.paint(); } } /// The whole `--attach` run: connect, then `attachLoop` until the session, the /// link or the terminal ends it. The terminal is already raw, on the alt screen /// and reporting the mouse — `run` did that, and `run`'s defers undo it, which /// is what makes an attach leave a terminal in exactly the state an ordinary /// exit does. /// /// No `Options` reaches here any more. Every field of it describes a core, and /// the last two this frontend read went with the work that read them: the /// config directory served a `dump_themes` the daemon now does itself, and /// `nested` gated a listener for children this process no longer has. fn attachSession(init: std.process.Init, name: []const u8, tty: *vaxis.Tty, vx: *vaxis.Vaxis) AttachEnd { const io = init.io; const gpa = init.gpa; // The hello carries this window, so the size has to be real before it goes // out: a session told 80x24 by a 200x50 terminal reflows every pane twice, // once now and once on the first SIGWINCH. `vx.resize` here rather than // waiting for the loop's first event for the same reason — the first frame // may arrive before any terminal event does, and it has to have somewhere // to be painted. const ws = tty.getWinsize() catch |err| return .{ .lost = err }; if (ws.cols == 0 or ws.rows == 0) return .{ .lost = error.NoWinsize }; vx.resize(gpa, tty.writer(), ws) catch |err| return .{ .lost = err }; // The SAME three steps the `Attach` builtin takes, through the same // function, because the two entries drifted apart the last time they were // written separately: `--attach` resolved a bare name and the builtin did // not, so the documented `SPC s a` answered `NoSessionPath` at a session // that was listening. `attempt` resolves, connects, and waits to be // GREETED. This path could afford to meet a refusal inside the loop below // — it has no local session to lose — but there is no second sequence to // maintain, so it does not have its own. var attempt = detached_client.attempt(gpa, name, ws.cols, ws.rows); var client = switch (attempt) { .greeted => |c| c, else => return attemptEnd(&attempt, name), }; // `detach` and not `deinit`: seven bytes that turn "the peer vanished" into // "the peer left" in the session's log. defer client.detach(); var loop: Loop = .init(io, tty, vx); var paste_buf: std.Io.Writer.Allocating = .init(gpa); defer paste_buf.deinit(); var a: Attach = .{ .gpa = gpa, .client = &client, .loop = &loop, .vx = vx, .tty = tty, .paste_buf = &paste_buf, // Concrete here, unlike the builtin's path: `run` resolved a bare // `--attach` to one name before it opened the terminal, and it outlives // this call. So a `Detach` from a `--attach` frontend can say what to // come back to. .session = name, }; // The whole teardown this frontend owes, which is now one queue drain: no // ptys, no watches, no workers, nothing forked. Registered BEFORE // `loop.stop()` below so LIFO runs it after — vaxis's reader has to be // joined before the queue is emptied, or a late post lands in a queue // nobody drains again and its bytes leak. defer drainAttachedQueue(&loop, gpa); loop.start() catch |err| return .{ .lost = err }; defer loop.stop(); // Detached rather than an `io.concurrent` task, for `run`'s reason: sigwait // never returns, so a task around it would hang the teardown that joins it. (std.Thread.spawn(.{}, winchWatch, .{ &loop, vx, tty }) catch |err| return .{ .lost = err }).detach(); // Send the capability probes and do not wait on them; `Attach.enableCaps` // resolves them on the loop. run() has the long version of why. vx.queryTerminalSend(tty.writer()) catch {}; return attachLoop(&a); }