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) --- test/panes.zig | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 5 deletions(-) (limited to 'test/panes.zig') diff --git a/test/panes.zig b/test/panes.zig index d3c29856..e4e677e4 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -731,6 +731,72 @@ const TtySelectionTests = struct { } } } + + /// Every byte the pane's program would have read off its pty since the + /// last call, joined. + fn childInput(p: *Pardes, buf: []u8) []const u8 { + var n: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + const bytes = w.bytes.slice(); + if (n + bytes.len > buf.len) break; + @memcpy(buf[n..][0..bytes.len], bytes); + n += bytes.len; + }, + else => {}, + }; + return buf[0..n]; + } + + // Raw tty mode forwards nearly every key to the child; the two paste + // chords are the exception, and forwarding them instead is what broke + // pasting into a terminal pane. An application that gets Ctrl-V rather + // than the text goes and reads the desktop clipboard on its own account, + // and a coding agent reading it is looking for an image, not for words. + test "raw tty paste chords type at the program instead of reaching it as keys" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + for ([_]bool{ false, true }) |bracketed| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane)); + p.yank = try p.gpa.dupe(u8, "one\ntwo"); // what a `y` anywhere left behind + var buf: [256]u8 = undefined; + _ = childInput(p, &buf); + + // Ctrl-V types the register straight at the program, asking the + // desktop for nothing. Under DECSET 2004 the program's own fences + // carry it, which is what stops readline RUNNING a multi-line paste. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~one\ntwo\x1b[201~" else "one\rtwo", + childInput(p, &buf), + ); + try std.testing.expect(p.clip_pending == null); + + // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and + // the answer lands at the program rather than in an edit buffer. A + // host that folds the Shift into the letter says the same thing. + for ([_]Key{ + .{ .cp = 'v', .ctrl = true, .shift = true }, + .{ .cp = 'V', .ctrl = true }, + }) |chord| { + p.update(.{ .key = chord }); + try std.testing.expect(p.clip_pending != null); + try std.testing.expectEqualStrings("", childInput(p, &buf)); + p.update(.{ .paste = "from-desktop" }); + try std.testing.expect(p.clip_pending == null); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~from-desktop\x1b[201~" else "from-desktop", + childInput(p, &buf), + ); + } + // ...and neither chord put the clipboard in the register on its way. + try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); + } + } }; const ClickCursorTests = struct { @@ -1616,7 +1682,7 @@ test "generated output refuses every pipe prompt before seeding the tag" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const content = try p.gpa.dupe(u8, "generated\n"); - const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content); + const pane = try panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", content); p.active = 1; for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| { p.update(.{ .key = key }); @@ -1825,14 +1891,14 @@ test "file history construction failures preserve existing panes" { defer if (incoming) |bytes| p.gpa.free(bytes); failing.fail_index = failing.alloc_index + failure; const result = switch (case) { - .open => panes.File.open(p, 1, path, 0), + .open => panes.File.open(p, p.freeSlot().?, path, 0), .restore => panes.File.restore(p, 1, .{ .kind = .file, .tag = "", .body = "", .file = .{ .path = "/restored.zig", .content = "restored\n" }, }), - .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?), + .output => panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", incoming.?), .tutor => p.openTutorView(1), .replace => p.setTestFile("replacement\n"), }; @@ -1900,14 +1966,14 @@ test "file history owns bounded snapshot storage without per-edit bookkeeping al try std.testing.expectEqual(@as(usize, 0), history.redo_len); } -test "file history allocation precedes a filesystem read with side effects" { +test "file history allocation precedes reading the served tree" { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const original = p.panes[0].?; const serial = p.next_serial; failing.fail_index = failing.alloc_index + 2; - try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new", 0)); + try std.testing.expectError(error.OutOfMemory, panes.File.open(p, p.freeSlot().?, "/n/self/index", 0)); try std.testing.expect(failing.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqual(serial, p.next_serial); -- cgit v1.3