From 9070942b29bd10dddcdecdb0e88ba0fb40608467 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 20:07:43 -0300 Subject: Plan 9 idiom for the control filesystem, and the regressions a624a56 left The 9P tree stops being a command language wearing a filesystem. /new created a pane as a side effect of a *read*; it is now Tcreate in /pane, with Tremove to close, which cloud9's engine has always supported and the editor never declared: tree.zig now says `features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs become files that can be read as well as written -- dot, limit, dirty, mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file would say better. Root /ctl splits into a read-only /status and the /look and /exec files whose write IS the click. stat carries real sizes where it used to answer 0, and qid versions track a pane's revision, so a client can poll for change without re-reading the body. Commit a624a56 moved raw-tty keys to an early-return branch that knew only Ctrl-B and bare Escape, and in the same edit deleted the paste branch below it. That cost Shift-Escape (the unconditional way out of tty mode) and both paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an agent CLI running in a pane took Ctrl-V for its image-paste binding and answered "No image found in clipboard". Both are restored, with tests. Nested detection was not subtly broken but deleted: 60367d8 removed nested.zig's process-ancestry walk and left "am I inside pardes" derived from PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener did not come up". PARDES_PID now answers that question on its own, checked with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag is gone. The posted-9P registry also self-heals now -- a session that aborts cannot unlink its own socket, so posting sweeps entries whose target refuses a connection, symlinks only and on a definite ECONNREFUSED only. Elsewhere: tty scrolling is sticky-bottom, following new output only from the last row, with typing and entering raw mode snapping back to live; the boot layouts are a Boot enum instead of a chain of ifs, and the bare tty startup (Boot.tty, which main.zig names) opens an empty text pane under the shell while tests keep Boot.tty_shell; builtins announce themselves on the message row under a Verbose setting that is on by default; Config prints each setting the way you would type it back, so WindowOpacity 70 rather than "WindowOpacity: 70%"; LocationsConfig opens its window only when called bare; every tagline puts the word that closes the thing last, and a column now outlives its panes -- closing the last one leaves an empty pane, and only Delcol, newly on the column tagline, takes the column away. Co-Authored-By: Claude Opus 5 (1M context) --- src/main.zig | 71 ++++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 14 deletions(-) (limited to 'src/main.zig') diff --git a/src/main.zig b/src/main.zig index 4b1910bd..1cbdf24c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -112,6 +112,16 @@ const nested_text = \\ ; +/// $PARDES_PID named a live editor, so this shell IS inside one, but the Look +/// never got there. Saying so beats quietly opening the second editor that +/// $PARDES_PID exists to prevent. +const unreachable_text = + \\pardes: this shell is inside pardes, but that session did not take the + \\file. Check that it is still running, or pass --nested to start a second + \\editor in here anyway. + \\ +; + // 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; @@ -260,6 +270,10 @@ fn nativeMain(init: std.process.Init) !void { } } opts.tty_only = explicit_tty or session_only; + // The real bare-tty startup wants the editor half present from the first + // frame, so it names that layout; a core booted for a test names none and + // gets the raw shell by itself. + if (opts.tty_only) opts.boot = .tty; opts.mounts = mounts[0..mounts_len]; if (detach != null and attach != null) badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); @@ -272,31 +286,60 @@ fn nativeMain(init: std.process.Init) !void { if (opts.mounts.len != 0 and attach != null) badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { - const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; - if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; - const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); - ninep_io.Client.validateDial(dial) catch break :forwarding; - const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); - for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; - const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; - if (serial == 0) break :forwarding; - var ctl_buf: [64]u8 = undefined; - const ctl = try std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}); + // Am I inside a pardes at all? That is $PARDES_PID and nothing else — + // one variable, exported by every session that means to take its + // children's Looks, so a session that could not open a socket is still + // recognisably the session this shell is inside. A pid naming no live + // process is a shell that outlived its editor (a daemon started from a + // pane, a stale environment carried into a new terminal), which is not + // nested at all. + const owner = std.mem.span(std.c.getenv("PARDES_PID") orelse break :forwarding); + const pid = std.fmt.parseInt(std.c.pid_t, owner, 10) catch break :forwarding; + if (pid <= 0 or std.c.kill(pid, @enumFromInt(0)) != 0) break :forwarding; + + // Can I reach it? That is the other two, and it is a separate answer. + const Parent = struct { dial: []const u8, serial: u32 }; + const found: ?Parent = reaching: { + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :reaching null); + ninep_io.Client.validateDial(dial) catch break :reaching null; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :reaching null); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :reaching null; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :reaching null; + if (serial == 0) break :reaching null; + break :reaching .{ .dial = dial, .serial = serial }; + }; + const parent = found orelse { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; + // The pane's `look` file: one line, and the line is the clicked text + // itself, which is what a right click in that pane would have been. + var look_buf: [64]u8 = undefined; + const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial}); const word = positional orelse { var tag_buf: [64]u8 = undefined; - const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{serial}); - const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); + const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; + // A word this launch cannot turn into a path is an argument problem + // rather than a nesting one: fall through and let the session that + // starts report it, the way `pardes typo` outside pardes does. if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, look, command) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; return; } if (positional) |a| { -- cgit v1.3