summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 10:13:45 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit87bb23d39df92db0b383dd300a9ebd1ad9cb2b7a (patch)
treea54e857ae9f999ef9eee96b07d6761a86ccc7b1b
parentae7aba431c1a152dc999e485e8285ebd285811f8 (diff)
downloadpardes-87bb23d39df92db0b383dd300a9ebd1ad9cb2b7a.tar.gz
pardes-87bb23d39df92db0b383dd300a9ebd1ad9cb2b7a.zip
Focus gives the keyboard only, reads empty while a header has it, and sits in the root listing's natural place
A write to /focus leaves a folded pane folded, as rio keeps current apart from unhide; while a column or workspace tag has the keyboard no pane does, so /focus reads empty and every pane's ctl says notcurrent (an empty read also no longer answers stale staged bytes). focus, ctl and commands now list after status, and the discovery test checks the root as a set rather than by position. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md3
-rw-r--r--docs/fs.md6
-rw-r--r--src/ninep/ctl.zig27
-rw-r--r--src/ninep/tree.zig18
-rw-r--r--test/fs.py5
-rw-r--r--test/fs_bench.zig6
6 files changed, 44 insertions, 21 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index a17a5dac..ef37db6f 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -52,7 +52,8 @@ $m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg
(exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not)
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
-$m/focus the serial of the pane with the keyboard; echo a serial into it to move the keyboard
+$m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it);
+ echo a serial into it to move the keyboard (a folded pane stays folded)
$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin
(Newcol, Dump, Kill, Theme x); a pane's builtins (Del, Save f) go to $m/pane/<n>/ctl
$m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it)
diff --git a/docs/fs.md b/docs/fs.md
index c08500f1..5c06d206 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -135,7 +135,11 @@ generated from the registry, so it is always this build's own list.
`/focus` reads the serial of the pane with the keyboard, and a serial written
to it gives that pane the keyboard, off any column or workspace tag that had
it -- rio's `current` written to a window's `wctl`, named once for the whole
-tree since there is one keyboard. A serial no pane has fails with `no such
+tree since there is one keyboard. While a column's or the workspace's tag has
+the keyboard no pane does: `/focus` reads empty and every pane's `ctl` says
+`notcurrent`. A write gives the keyboard and nothing else: a folded pane
+stays folded (unfold it with `Collapse` on its ctl), as rio keeps `current`
+apart from `unhide`. A serial no pane has fails with `no such
window`; anything but a number, with `ill-formed control message`.
A pane is made by **opening** `/pane/new`, and closed by Tremove on
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index e47dec16..4afc82dc 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -12,6 +12,7 @@ const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
const tagline = @import("../tagline.zig");
+const layout = @import("../layout.zig");
const builtins = @import("../builtins.zig");
const exec_line = @import("../exec.zig");
const tree = @import("tree.zig");
@@ -162,7 +163,9 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
}) catch return w.buffered();
const font = p.settings.font.effective_name.get();
writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered();
- w.print(" {d:>11} {s} ", .{ config.tab_width, if (p.panes[p.active] == pane) "current" else "notcurrent" }) catch {};
+ // While a column's or the workspace's tag has the keyboard, no pane does.
+ const current = p.panes[p.active] == pane and !p.header_focus;
+ w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {};
return w.buffered();
}
@@ -333,10 +336,15 @@ pub fn readCommands(p: *Pardes, req: Req) Reply {
// ---- /focus ----
/// The pane with the keyboard, which a write of a serial moves, the way
-/// writing `current` to rio's wctl does (rio(4)).
+/// writing `current` to rio's wctl does (rio(4)). While a column's or the
+/// workspace's tag has it, no pane does, and this reads empty. A write
+/// gives the keyboard and nothing else: a folded pane stays folded, as
+/// rio keeps `current` apart from `unhide`.
pub fn readFocus(p: *Pardes, req: Req) Reply {
+ const out = p.fs.stage(p.gpa);
+ if (p.header_focus) return tree.stagedReply(p, req);
const pane = p.panes[p.active] orelse return tree.stagedReply(p, req);
- p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{pane.serial}) catch return Reply.fail(req.tag, E.NOMEM);
+ out.print(p.gpa, "{d}\n", .{pane.serial}) catch return Reply.fail(req.tag, E.NOMEM);
return tree.stagedReply(p, req);
}
@@ -506,8 +514,11 @@ test "focus reads the pane with the keyboard, and a serial written to it moves t
try testing.expectEqual(@as(u64, rd(p, focus, 0, 64).bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size);
try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " notcurrent "));
- // A header had the keyboard: the write takes it back to the pane.
+ // A header has the keyboard: no pane does, and a write takes it back.
tagline.enterHeader(p, null);
+ try testing.expectEqualStrings("", rd(p, focus, 0, 64).bytes);
+ try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size);
+ try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent "));
try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}\n", .{first})).reply.status);
try testing.expect(!p.header_focus);
try testing.expectEqual(p.paneBySerial(first).?, p.active);
@@ -518,6 +529,14 @@ test "focus reads the pane with the keyboard, and a serial written to it moves t
try testing.expectEqual(E.NOENT, wr(p, focus, "99999").errno());
for ([_][]const u8{ "", "two", "-1", "1 2" }) |bad| try testing.expectEqual(E.INVAL, wr(p, focus, bad).errno());
try testing.expectEqual(p.paneBySerial(first).?, p.active);
+
+ // Focus gives the keyboard only: a folded pane stays folded.
+ const folded = p.paneBySerial(second).?;
+ layout.toggleCollapse(p, folded);
+ try testing.expect(p.panes[folded].?.collapsed);
+ try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}", .{second})).reply.status);
+ try testing.expectEqual(folded, p.active);
+ try testing.expect(p.panes[folded].?.collapsed);
}
test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the rest in Plan 9's words" {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 27ad02c8..e9c6b8ea 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -438,6 +438,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.ctl => ctl.rootLen(p),
.commands => ctl.commandsLen(),
.focus => focus: {
+ if (p.header_focus) break :focus 0;
const pn = p.panes[p.active] orelse break :focus 0;
var digits: [16]u8 = undefined;
break :focus (std.fmt.bufPrint(&digits, "{d}\n", .{pn.serial}) catch unreachable).len;
@@ -516,14 +517,12 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| switch (f) {
.root => {
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| {
+ // 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.pane }) |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");
- // After the first ten, which older clients list by position.
- inline for (.{ TopFile.focus, TopFile.ctl, TopFile.commands }) |t| {
- if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName());
- }
sources.stage(p, out, "", &skip);
},
.pane => {
@@ -901,13 +900,12 @@ 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", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 8, top[i].dir);
+ try testing.expectEqual(i >= 11, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node);
- try testing.expectEqual(fs.os_root, top[9].node);
- try testing.expectEqualStrings("focus", top[10].name);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[11].node);
+ try testing.expectEqual(fs.os_root, top[12].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 bafe665c..c7552a2b 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -144,8 +144,9 @@ def discovery(binary, embedded=False):
root = Path(directory)
with session(binary, root, 'discovery') as (client, address):
top = client.list('/')
- assert top[:10] == ['README', 'index', 'status', 'look', 'exec', 'log', 'screen',
- 'listeners', 'pane', 'os'], top
+ # 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', 'pane', '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)
before = client.read('/index')
diff --git a/test/fs_bench.zig b/test/fs_bench.zig
index 75b2eb11..746fe737 100644
--- a/test/fs_bench.zig
+++ b/test/fs_bench.zig
@@ -133,8 +133,8 @@ fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void {
const bytes = s.core.fs.out.items;
if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size)
return error.IncorrectReply;
- const names = [_][]const u8{ "README", "index", "status", "look", "exec", "log", "screen", "listeners", "pane", "os" };
- const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.status), @intFromEnum(ctlfs.TopFile.look), @intFromEnum(ctlfs.TopFile.exec), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root };
+ const names = [_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "pane", "os" };
+ const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.status), @intFromEnum(ctlfs.TopFile.focus), @intFromEnum(ctlfs.TopFile.ctl), @intFromEnum(ctlfs.TopFile.commands), @intFromEnum(ctlfs.TopFile.look), @intFromEnum(ctlfs.TopFile.exec), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root };
var off: usize = 0;
var entry: usize = 0;
while (off < bytes.len) : (entry += 1) {
@@ -144,7 +144,7 @@ fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void {
const size: usize = bytes[off + 9];
off += 10;
if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply;
- if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry >= 8) or
+ if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry >= 11) or
!std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply;
off += size;
}