const std = @import("std"); const builtin = @import("builtin"); const pardes = @import("pardes.zig"); const nested = @import("nested.zig"); const is_emscripten = builtin.os.tag == .emscripten; extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void; // emscripten: std.debug's default threaded-io singleton doesn't run on wasm; // fail it (init errors surface via emscripten_console_error below). Native // values match the std defaults. Same shim as the prototype's replay.zig. pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten) null else std.Io.Threaded.global_single_threaded; pub const std_options_debug_io: std.Io = if (is_emscripten) std.Io.failing else std_options_debug_threaded_io.?.io(); // Every std.log call in the process — ours and every dependency's — funnels // through this one function. ghostty-vt narrates whatever it does not // implement in the bytes a child writes to its pty (`debug(stream)`, // `warning(stream): ignoring unimplemented CSI p`, `debug(kitty_gfx)`; opening // yazi is worth several lines before it has drawn anything), and in the tty // shell stderr IS the screen — those land on top of the rendered grid, and in // the gui/web shells on the console. So drop the libraries at every level: an // `err` painted over the UI is no better than a debug one. Only pardes' own // scopes get through, because their messages carry detail the error returns // don't (gui's SDL_GetError strings, dump's zon parse diagnostic) — .default // is NOT one of them, ghostty and uucode both log unscoped. Set PARDES_LOG to // get the real logger back: `PARDES_LOG=1 pardes 2>/tmp/pardes.log`. pub const std_options: std.Options = .{ .logFn = logFn }; fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { if (scope != .gui and scope != .dump and std.c.getenv("PARDES_LOG") == null) return; std.log.defaultLog(level, scope, format, args); } // A panic must restore the terminal (cooked mode, main screen, mouse off) // before the trace prints, or it lands garbled in a raw alt screen. recover() // no-ops unless the vaxis tty is live, so gui/tty share the handler. pub const panic = if (is_emscripten) std.debug.FullPanic(std.debug.defaultPanic) else std.debug.FullPanic(struct { fn call(msg: []const u8, ret_addr: ?usize) noreturn { @import("vaxis").recover(); std.debug.defaultPanic(msg, ret_addr); } }.call); // Fatal signals (SIGSEGV/SIGILL/SIGBUS/SIGFPE) bypass the panic handler and // no defer/errdefer ever runs — hook std.debug's segfault path the same way // so the terminal is restored before the trace prints. pub const debug = if (is_emscripten) struct {} else struct { pub fn handleSegfault(addr: ?usize, name: []const u8, opt_ctx: anytype) noreturn { @import("vaxis").recover(); return std.debug.defaultHandleSegfault(addr, name, opt_ctx); } }; const help_text = \\Usage: pardes [options] [file|dir] \\ \\With no arguments the shell pane starts in tty mode. A FILE argument \\(optionally file:LINE) opens it alone, filling the window; a DIRECTORY \\argument becomes the cwd shells spawn in. \\ \\Options: \\ --tty start as one shell pane already in tty mode \\ -n initial shell panes: 1 (default) or 3 (classic). \\ A FILE argument boots just that file instead. \\ --tty-toggle use Ctrl- to enter/leave tty mode \\ -l load a dump of another instance (see Dump) \\ --nested run a full session even inside another pardes. \\ Without it, a pardes started inside a pardes \\ hands its FILE argument to the outer one. This \\ session will not serve its own children either. \\ --fs serve acme's control filesystem for this session \\ under $XDG_RUNTIME_DIR/pardes/, and export \\ PARDES_FS and PARDES_PANE into every pane shell \\ --fs= ...at instead. Must be absolute; pardes \\ unmounts it on exit but leaves the directory \\ --detach run this session with NO terminal of its own, \\ serving frontends over a unix socket beside the \\ nested-instance one. The core, the panes and the \\ undo history outlive every frontend that attaches \\ --detach= ...named rather than this process's pid, so \\ a frontend can say which session it wants. One \\ path component: no '/' and nothing empty \\ --attach become a frontend of the one detached session \\ that is running: draw its screen, send it input, \\ fork its pane shells. Several frontends may be \\ attached at once and all see the same screen \\ --attach= ...of the session called , which is what to \\ use when more than one is running \\ -h, --help show this help and exit \\ ; const nested_text = \\pardes: this shell is already inside pardes, and a pardes inside a pardes \\is spicy. Name a file or a directory and the outer session opens it, or \\pass --nested if you really want a second one in here. \\ ; // The browser runtime calls a C main (exported below); everything else keeps // the std.process.Init entry. pub const main = if (is_emscripten) webMain else nativeMain; comptime { if (is_emscripten) @export(&emscriptenMain, .{ .name = "main" }); } fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int { _ = argc; _ = argv; webMain() catch |err| { var buf: [128]u8 = undefined; const msg = std.fmt.bufPrintSentinel(&buf, "pardes web init failed: {t}", .{err}, 0) catch "pardes web init failed"; emscripten_console_error(msg.ptr); return 1; }; return 0; } // no argv in the browser: options stay default, state comes from the dump fn webMain() !void { try @import("gui/gui.zig").run(.{}); } fn nativeMain(init: std.process.Init) !void { var opts: pardes.Options = .{}; const arena = init.arena.allocator(); const args = try init.minimal.args.toSlice(arena); // bare `pardes` boots straight into tty mode — and so does a `pardes` // carrying nothing but flags that say something about the SESSION rather // than about its layout: --nested is about this session's relationship to // its parent, --fs is about who may script it, --detach is about who may // WATCH it, --attach is about whose screen this one is showing, and none of // the four says anything about what should be on screen. Anything else (a // FILE, -n, --tty) is layout, and answers this question itself further // down. opts.tty_only = for (args[1..]) |a| { if (!std.mem.eql(u8, a, "--nested") and !std.mem.eql(u8, a, "--fs") and !std.mem.startsWith(u8, a, "--fs=") and !std.mem.eql(u8, a, "--detach") and !std.mem.startsWith(u8, a, "--detach=") and !std.mem.eql(u8, a, "--attach") and !std.mem.startsWith(u8, a, "--attach=")) break false; } else true; // `--detach[=]`: null when it was not given, so the empty string is // free to mean "the default name" the way opts.fs uses it for a directory. var detach: ?[]const u8 = null; // ...and `--attach[=]`, the same shape: null when it was not given, // and the empty string means "the one session there is" (tty.zig // `sessionName`) rather than a session with no name. var attach: ?[]const u8 = null; // Kept RAW until every flag is parsed: classifying it means chdir'ing into // a directory and recording nothing, and the nested client below still // needs the word itself to resolve. var positional: ?[:0]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--tty")) { opts.tty_only = true; } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) { opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs; } else if (std.mem.eql(u8, a, "--tty-toggle")) { i += 1; if (i >= args.len) return error.BadArgs; opts.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs; } else if (std.mem.eql(u8, a, "-n")) { i += 1; if (i >= args.len) return error.BadArgs; opts.shells = std.fmt.parseInt(u8, args[i], 10) catch return error.BadArgs; } else if (std.mem.eql(u8, a, "-l")) { i += 1; if (i >= args.len) return error.BadArgs; opts.load_path = args[i]; } else if (std.mem.eql(u8, a, "--fs")) { opts.fs = ""; } else if (std.mem.startsWith(u8, a, "--fs=")) { // `--fs=` and NEVER `--fs `, which is the one place this // parser cannot follow --tty-toggle: --tty-toggle's argument is // mandatory, so consuming the next word is unambiguous. `--fs` is // useful bare, so a two-word form would make `pardes --fs README` // mount at ./README and open no file — the flag would silently eat // the FILE argument. One spelling, and it carries its own value. opts.fs = a["--fs=".len..]; } else if (std.mem.eql(u8, a, "--nested")) { opts.nested = true; } else if (std.mem.eql(u8, a, "--detach")) { detach = ""; } else if (std.mem.startsWith(u8, a, "--detach=")) { // `--detach=` and never `--detach `, for exactly the // reason --fs gives above: the flag is useful bare, so a two-word // form would make `pardes --detach README` a session called README // that opens no file. detach = a["--detach=".len..]; } else if (std.mem.eql(u8, a, "--attach")) { attach = ""; } else if (std.mem.startsWith(u8, a, "--attach=")) { // `--attach=` and never `--attach `, for the reason // --fs states above and --detach repeats: the flag is useful bare, // so a two-word form would make `pardes --attach README` an attach // to a session called README that opens no file. attach = a["--attach=".len..]; } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { try std.Io.File.stdout().writeStreamingAll(init.io, help_text); return; } else if (a.len > 0 and a[0] != '-' and positional == null) { positional = a; } else { return error.BadArgs; } } // Started INSIDE another pardes: hand it the file and get out of the way // rather than stacking a second full-screen UI inside one of its panes. // The word is resolved here rather than sent raw because the outer // instance resolves against ITS panes' directories, which are not ours. // A word naming nothing on disk sends nothing and falls through to the // classification below, which already refuses it — no second UI either way. // // `--detach` is exempt for the same reason `--nested` is, arrived at from // the other side: it stacks no UI at all. A detached session started from a // pane is a session, not a request that the outer instance open something, // and handing it our positional would leave the caller with no session. // // `--attach` is exempt for the mirror of that: it stacks a UI, but the UI // is a session that already exists somewhere else, and handing our word to // the outer instance would open the file in the WRONG session and leave // the caller with no frontend. if (!opts.nested and detach == null and attach == null) if (nested.outer()) |outer_pid| { const word = positional orelse { try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; var cwdbuf: [4096]u8 = undefined; const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs; var realbuf: [4096]u8 = undefined; const sent = switch (@import("look.zig").resolve(word, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { .dir => |d| nested.sendLook(outer_pid, d, 0), .file => |t| nested.sendLook(outer_pid, t.path, t.at.line), .image => |t| nested.sendLook(outer_pid, t.path, 0), // an unreachable outer instance (an older build, a stale socket // path) is not worth failing a launch over: run normally instead else => false, }; if (sent) return; }; if (positional) |a| { // a directory becomes the cwd shells spawn in (chdir succeeds only on // dirs); anything else resolves as a file if (std.c.chdir(a.ptr) != 0) { var cwdbuf: [4096]u8 = undefined; const cwd = std.c.getcwd(&cwdbuf, cwdbuf.len) orelse return error.BadArgs; var realbuf: [4096]u8 = undefined; switch (@import("look.zig").resolve(a, std.mem.span(@as([*:0]u8, @ptrCast(cwd))), &realbuf)) { .file => |t| { opts.file = try arena.dupe(u8, t.path); opts.file_line = t.at.line; }, .image => |t| opts.file = try arena.dupe(u8, t.path), else => return error.BadArgs, } } } // Native shells opt into the user config; direct core callers and web keep // Options' null default. Read it before entering either frontend so every // builtin has run before that frontend can render its first frame. const found = @import("user_config.zig").load(init.io, arena, init.environ_map); opts.startup_config = found.bytes; opts.startup_config_path = found.path; opts.config_dir = found.dir; // `--detach` is the core with no terminal and `--attach` is a terminal // with no core, so the two together are a contradiction with no useful // reading. Refused rather than resolved by declaration order, which would // silently drop whichever flag lost. if (detach != null and attach != null) return error.BadArgs; // `--detach` replaces the frontend rather than choosing among them: the // core runs here, with no terminal, and the frontends are elsewhere on a // socket (src/detached/). It is checked before `platform` because it is not // a shell — the tty and gui builds can both be asked for one. if (detach) |name| { // Bare `--detach` is named by this process's pid, which is the one name // nobody has to be told and no two sessions can share. Unsigned: `{d}` // prints a leading '+' for a positive SIGNED int, which is nested.zig's // note about the same cast. const named = if (name.len != 0) name else try std.fmt.allocPrint(arena, "{d}", .{@as(u32, @intCast(std.c.getpid()))}); return @import("detached/server.zig").run(init, opts, named); } // A frontend is a SHELL, and only the tty one knows how to be one today. // Comptime-folded, so a tty build carries none of this. if (attach != null and pardes.platform != .tty) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: --attach needs the tty shell\n"); std.process.exit(1); } switch (pardes.platform) { .tty => try @import("tty/tty.zig").run(init, opts, attach), .gui => try @import("gui/gui.zig").run(init, opts), // Every other shell is entered by its host and never links this file // at all: the browser through src/web.zig, the macOS app through // src/macos.zig, and the ESP32-P4 firmware through src/esp32p4/app.zig, // which is a root of its own in this repository and links the // `pardes-esp32p4` object over the C ABI in src/esp32p4.zig. .web, .macos, .esp32p4 => unreachable, } } fn parseCtrlKey(raw: []const u8) ?u21 { var value = std.mem.trim(u8, raw, " \t\r\n"); if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) { value = value[5..]; } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) { value = value[5..]; } else if (value.len == 2 and value[0] == '^') { value = value[1..]; } if (value.len != 1) return null; const c = std.ascii.toLower(value[0]); if (c < 'a' or c > 'z' or c == 'c') return null; return c; } // The shells are imported inside main(), which a test build never analyses — // so their inline tests need naming here to exist at all. Each shell only // compiles when selected (GUI @cImports SDL; TTY imports vaxis), hence the // comptime gates. Naming a file gets THAT file's tests and no further: // fonts.zig is imported by gui.zig, builtins.zig and the macOS host, and still // needs its own line here. test { _ = @import("user_config.zig"); _ = @import("allocators.zig"); _ = @import("fs_service.zig"); // acme's control filesystem, both halves, and NOT their own b.addTest // modules in build.zig the way temp_file/nested/fonts are: both reach // src/pardes.zig (acmefs takes a *Pardes, fuse.zig speaks its Req/Reply), // so a standalone module would have to re-wire ghostty-vt, tree-sitter, the // themes and every option the core imports. This module already has them. // // fuse.zig genuinely needs its name here (nothing the core analyses reaches // it — only the shells import it). acmefs.zig does not today, because // pardes.zig re-exports it unconditionally; it is named anyway, because the // day that re-export grows a comptime gate is the day 23 tests disappear in // silence. That is the fonts.zig story above, told once already. _ = @import("acmefs.zig"); _ = @import("fuse.zig"); // The detached-session transport (src/detached/), same story as fuse.zig // above: it speaks the core's Event/Surface, so it belongs in THIS module // rather than a standalone b.addTest, and nothing the core analyses reaches // it. A TTY build does — tty.zig imports client.zig for `--attach` — but // these names are what makes the transport's tests exist in every other // build too, and `--detach` is not a tty-only feature. // client.zig's own tests drive a real `Session` over a real socket, so // naming it reaches server.zig too — but server.zig is named anyway, for // the acmefs.zig reason: the day client.zig stops importing it is the day // those tests vanish in silence. _ = @import("detached/wire.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); if (comptime pardes.platform == .tty) { _ = @import("tty/tty.zig"); // tty.zig calls the compositor only from its runtime loop, so merely // naming the shell does not make Zig analyse the compositor's tests. _ = @import("tty/panel_compositor.zig"); } if (comptime pardes.platform == .gui) _ = @import("gui/gui.zig"); if (comptime pardes.font_picker) _ = @import("fonts.zig"); }