From 5b11fc35b961eb03c8816bf957bbf8e27a22ae3e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 01:48:39 -0300 Subject: The workspace tag has /tagexec, every exec file reads back what it touched, and a tag's words log as the session's /tagexec runs a word as a click in the workspace tag, as col//exec does in a column's. A column's exec was write-only; it and /tagexec now read back the serials the last write touched, as /exec does. A click in a tag no longer goes to the focused pane's event reader, and what a word run from a column's ctl or exec or /tagexec says is logged msg -, not against the focused pane. The README, fs.md and the skill say so, and document the answer record. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 3 ++- docs/fs.md | 13 +++++++++--- src/fs-help.txt | 8 ++++---- src/ninep/cols.zig | 42 +++++++++++++++++++++++++++++++++------ src/ninep/ctl.zig | 7 ++++--- src/ninep/tree.zig | 36 +++++++++++++++++++++------------ test/fs.py | 2 +- 7 files changed, 80 insertions(+), 31 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 97050e25..01be232d 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -85,6 +85,7 @@ $m/ctl the settings, one a line as a write takes them; write a setting or $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) $m/layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; active $m/tag the workspace tag (> replaces, >> appends, one line); $m/col//tag a column's (serials stay, as panes' do) +$m/tagexec a word as a click in the workspace tag; every exec file reads back the serials its last write touched $m/col//ctl Delcol, Joincol, New, Tty on that column; col//exec a word as a click in its tag; rmdir col/ closes an empty column $m/pane/new open it to make a pane (a scratch named /+New), read names it; it goes in @@ -154,7 +155,7 @@ record written back from such a file's body goes to its REPL, as the click would. `Repl -` unbinds; a bare `Repl` says the binding. With several REPLs bound for a language an exec asks which, logged `ask repl a b` (Del's side from the keyboard is `ask del k j`): answer with -`echo 'answer a' > $m/pane//ctl`, or `answer -` to send nothing. Multi-line code +`echo 'answer a' > $m/pane//ctl`, or `answer -` to send nothing (the log says `answer a|-`). Multi-line code written to `pty/data` should be a bracketed paste, `\e[200~\e[201~`, then, in a separate write once the REPL has echoed the paste (Python 3.13+ takes a `\r` read with the paste as part of it, even for one line), `\r`, diff --git a/docs/fs.md b/docs/fs.md index 0a688039..ad9c97ba 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -110,9 +110,10 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. empty|full pane-serials...; then active : the active column, where pane/new and a look place a pane next (- when there is none) /tag the workspace tag; > replaces it, >> appends, one line +/tagexec write a word: a middle click on it in the workspace tag; read as /exec /col//tag the tag of the column with serial n, the same way /col//ctl write Delcol, Joincol, New or Tty: each acts on that column, as from its tag -/col//exec write a word: a middle click on it in that column's tag; rmdir col/ closes +/col//exec write a word: a middle click on it in that column's tag; read as /exec; rmdir col/ closes an empty column (a column with panes is refused, ENOTEMPTY) /pane/new open it to make a pane; the read answers that pane's serial. A session holds 64 panes (16 on the board); at that, every route that would open one -- this @@ -363,7 +364,10 @@ itself, holding the Enter until the REPL answers the paste), since line by line wherever it is -- at a `pdb` or `input()` prompt too. Bindings are not dumped, so a Restore leaves none. The root's pair clicks at the active pane and `/pane//look` and -`/pane//exec` at that pane. Blank lines are skipped, and every other line +`/pane//exec` at that pane; `/tagexec` and `/col//exec` click in the +workspace's or that column's tag (never an event reader's, which hears +only its pane's), and what the word says is logged as the session's, +`msg -`. Blank lines are skipped, and every other line is checked before any of them runs, so a control character fails the whole write with EINVAL; a command that fails inside the editor is reported on the message row, not as a write error. Reading any of these files answers the @@ -576,6 +580,8 @@ it runs `ls` once at its first prompt, a greeting that shows the directory -- since a terminal is named by its directory), `exit ` before the `del` of a terminal whose shell exited by itself, `ask ` when a pane asks a question (answered by `answer` on its ctl), +`answer ` when it is answered, by key or ctl, `-` for +taken back, and `save `, `dump ` when a Dump is written and `restore ` in a Restore's new log after its panes' `new`s, then `restored ` for each pane, @@ -584,7 +590,8 @@ mapping the serial it had to the one it has now, and `restoredcol for every line the editor says, repeats included (with `verbose` on, that includes each builtin announcing itself as it runs, on purpose: the log says which ran; its serial is the pane it ran at, `-` when the keyboard was on a -column or workspace tag), and `err +column or workspace tag, or the line came to the root's ctl, `/tagexec` or +a column's ctl or exec), and `err : ` for every write or truncation the tree refused or that failed -- through a mount a shell sees only the errno its kernel mapped the reply to, usually `Invalid argument`, and this is the reason (`err 3 addr: diff --git a/src/fs-help.txt b/src/fs-help.txt index 2492e933..3d84fca2 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -4,15 +4,15 @@ README this guide index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column serial status pid, version and pane count look write a line: a right click on it at the active pane; read: the serials it touched -exec write a line: a middle click, an editor command word or a shell line; read the same -log events: new del rename save newcol delcol run exit send ask dump restore restored restoredcol msg err +exec write a line: a middle click, an editor command word or a shell line; read the same (every exec file) +log events: new del rename save newcol delcol run exit send ask answer dump restore restored restoredcol msg err screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it layout a line per column: serial index x width current|notcurrent empty|full pane-serials; active -tag the workspace tag; col// a column's tag, ctl (Delcol Joincol New Tty) and exec; rmdir: empty only +tag the workspace tag, tagexec a word as a click in it; col// a column's tag, ctl (Delcol Joincol New Tty), exec; rmdir: empty only pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial pane// name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes @@ -40,6 +40,6 @@ Pitfalls, one each: tag reads the path, then its own text; > replaces that text (default words too), >> appends. A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream. exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in. - Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why. + Through a mount a malformed write is EINVAL, a failed one EIO or an errno that fits (ENOENT): log's err says why. lock/unlock need a held ctl fd (exec 3>ctl); a held lock fails: retry. fs.md has the rest. Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands. diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index 49df715a..8a3474b3 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -123,8 +123,11 @@ pub fn released(p: *Pardes, serial: ?u32) void { } /// A column's ctl: the words that act on a column, as its tag's clicks do -- -/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. +/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. What +/// they say is the session's in the log (`msg -`), not the focused pane's. pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { + p.fs.session_write = true; + defer p.fs.session_write = false; var lines = std.mem.splitScalar(u8, req.data, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); @@ -142,18 +145,24 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -/// A column's exec: each line run as a middle click on it in that column's -/// tag, acting on that column. -pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply { +/// A column's exec (`serial`), or the workspace tag's (/tagexec, null): +/// each line run as a middle click on it in that tag, a column's acting on +/// that column. A read answers the panes the last write touched, as /exec's +/// does; what the words say is the session's in the log (`msg -`). +pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { + p.fs.results_len = 0; + p.fs.session_write = true; + defer p.fs.session_write = false; var lines = std.mem.splitScalar(u8, req.data, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); - const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT); + const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null; + if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); p.exec_column = col; defer p.exec_column = null; - pardes.ctlfs.ctl.run(p, p.active, true, line); + pardes.ctlfs.ctl.run(p, p.active, true, line, true); } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -284,3 +293,24 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on // The root's ctl points a column word at the column's own. try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col//ctl")); } + +test "tagexec runs a word as a click in the workspace tag; exec files read back what they touched; their messages are the session's" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status); + try testing.expectEqual(@as(usize, 2), p.ncol); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Msg from the top\n").reply.status); + try testing.expect(th.logHas(p, "msg - from the top\n")); + // A column's exec makes a pane there, reads it back, and its Msg is + // the session's too. + const right = layout.columnSerial(p, 1); + try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status); + const made = p.panes[p.col_panes[1][0]].?.serial; + var want: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes); + try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "Msg from a column\n").reply.status); + try testing.expect(th.logHas(p, "msg - from a column\n")); +} diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 65878feb..c3c70667 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -51,14 +51,15 @@ fn noteResult(p: *Pardes, serial: u32) void { /// Runs one look or exec from the pane `id`, then records the panes it /// created; failing that, a look records the pane it focused and an exec /// the pane it acted on (even one it closed). -pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { +pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void { const context = p.panes[id].?.serial; // A pane an event reader holds hears its clicks instead of acting on // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), // and a line written to a look or exec is a click: on that pane, or for // the root's, on the pane with the keyboard. The reader writes the // record back to have it done. - if (p.panes[id].?.fs.readers > 0) { + // A click in a column's or the workspace's tag is not the pane's. + if (!in_tag and p.panes[id].?.fs.readers > 0) { const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null; var flag: u32 = pardes.ctlfs.events.flag_expansion; if (named) flag |= pardes.ctlfs.events.flag_builtin; @@ -139,7 +140,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); p.fs.no_pane_slot = false; - run(p, id, exec, text); + run(p, id, exec, text, false); // A pane it would have opened had no slot: the write fails, and // a look reads back nothing. if (p.fs.no_pane_slot) { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index baf8c772..085d03a3 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -191,6 +191,7 @@ pub const TopFile = enum(u5) { layout, tag, col, + tagexec, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -199,7 +200,7 @@ pub const TopFile = enum(u5) { pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane, .col => 0o755, - .look, .exec, .log, .focus, .ctl, .tag => 0o666, + .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666, .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444, }; } @@ -468,10 +469,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .dir = c.file == .dir, .mode = switch (c.file) { .dir => 0o755, - .ctl, .exec => 0o222, - .tag => 0o666, + .ctl => 0o222, + .tag, .exec => 0o666, + }, + .size = switch (c.file) { + .tag => cols.headerText(p, c.serial).?.len + 1, + .exec => ctl.resultsLen(p), + else => 0, }, - .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0, .mtime = p.fs.started, .append = c.file == .tag, }; @@ -513,7 +518,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), - .look, .exec => ctl.resultsLen(p), + .look, .exec, .tagexec => ctl.resultsLen(p), .log => events.logLen(&p.fs.log), .listeners => listeners: { var buf: [screen.listeners_cap]u8 = undefined; @@ -618,7 +623,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { .root => { // The guide, then what describes the session, then the // files that act, then the directories. - inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -861,7 +866,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { break :index stagedReply(p, req); }, .status => ctl.readStatus(p, req), - .look, .exec => ctl.readResults(p, req), + .look, .exec, .tagexec => ctl.readResults(p, req), .log => events.readLog(p, req), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), @@ -899,7 +904,11 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.read(p, req, id, pn, t.file); }, - .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM), + .col => |c| return switch (c.file) { + .tag => cols.read(p, req, c.serial), + .exec => ctl.readResults(p, req), + else => Reply.fail(req.tag, E.PERM), + }, } } @@ -911,6 +920,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { .focus => ctl.writeFocus(p, req), .ctl => ctl.writeRoot(p, req), .tag => cols.write(p, req, null), + .tagexec => cols.writeExec(p, req, null), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -1069,13 +1079,13 @@ test "readdir lists the root and a pane directory without creating anything" { @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); try testing.expect(top.len >= 10); - for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| { + for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 13, top[i].dir); + try testing.expectEqual(i >= 14, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node); - try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node); - try testing.expectEqual(fs.os_root, top[15].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node); + try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node); + try testing.expectEqual(fs.os_root, top[16].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); diff --git a/test/fs.py b/test/fs.py index cb781124..0e38f950 100644 --- a/test/fs.py +++ b/test/fs.py @@ -148,7 +148,7 @@ def discovery(binary, embedded=False): top = client.list('/') # A set: which files the root holds, not the order a listing gives. assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', - 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', + 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'col', 'os'}, top assert 'self' not in top and 'cons' not in top and 'new' not in top, top assert ('src' in top) == embedded, (top, embedded) -- cgit v1.3