From a7960117dfb83ad65a0466a96affbbb524936b7f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 03:14:31 -0300 Subject: A detached session with no frontend takes its size from the root ctl A --detach session nobody is attached to stayed 80x24, too small for an agent's panes, and nothing could change it. The root ctl takes size while no frontend is attached (refused while one owns the size), and a client that attaches later still sets its own (the server compares with the core's size). Documented with the 80x24 default. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 3 ++- docs/fs.md | 4 +++- src/detached/server.zig | 4 +++- src/ninep/ctl.zig | 30 ++++++++++++++++++++++++++++++ test/fs.py | 6 ++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 037f2d83..38f84e77 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -72,7 +72,8 @@ $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 (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 +$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin; + `size ` sizes a --detach session no frontend is attached to (80x24 before) (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes started (command panes, lines it typed into shells), a word matching a command line's first word; Exit and Restore refuse once, diff --git a/docs/fs.md b/docs/fs.md index e9dfac65..80735642 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -104,7 +104,9 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /listeners the session's dial addresses /focus the serial of the pane with the keyboard; write a serial to give it the keyboard, which makes its column the active one as a click there would -/ctl the settings, one a line as a write takes them; write a setting or a session builtin +/ctl the settings, one a line as a write takes them; write a setting or a session builtin; + `size ` sets the screen of a session no frontend is attached to + (`--detach`, 80x24 until then; refused while a frontend owns the size) /commands every builtin: word, `arg` if it takes one, `root` or `pane` (the ctl that takes it), a setting's values /layout one line per column, left to right: serial index x width current|notcurrent empty|full pane-serials...; then active : the active column, where diff --git a/src/detached/server.zig b/src/detached/server.zig index 8f45135a..af299051 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1076,7 +1076,9 @@ pub const Session = struct { rows = if (rows == 0) c.rows else @min(rows, c.rows); } var regridded = false; - if (cols != 0 and (cols != s.cols or rows != s.rows)) { + // Against the core's size too: a root ctl `size` may have set it + // while nobody was attached. + if (cols != 0 and (cols != s.cols or rows != s.rows or cols != s.core.screen_w or rows != s.core.screen_h)) { s.cols = cols; s.rows = rows; for (&s.clients) |*c| c.need_full = true; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 378dd844..26a18a12 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -472,6 +472,14 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { var it = Messages.init(p, req.data); while (it.next()) |line| { if (line.len == 0) continue; + if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) { + const size = sizeOf(line) orelse return refuse(p, req, "size takes ", line); + // A frontend's window is the size; only a session none is + // attached to (--detach, unattended) takes one written. + if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line); + if (apply) p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } }); + continue; + } if (!apply) { if (checkBuiltin(p, req, line, .session)) |refusal| return refusal; continue; @@ -485,6 +493,15 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } +/// `size `, each at least a few cells and at most 4096. +fn sizeOf(line: []const u8) ?[2]u16 { + var it = std.mem.tokenizeAny(u8, line["size".len..], " \t"); + const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; + const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null; + if (it.next() != null or cols < 20 or rows < 4 or cols > 4096 or rows > 4096) return null; + return .{ cols, rows }; +} + // ---- /commands ---- /// Every builtin the registry holds, one a line: its word, `arg` when it @@ -1460,3 +1477,16 @@ test "Edit's p and = never fail for want of room for +Errors: the text is logged for (p.panes) |slot| count += @intFromBool(slot != null); try testing.expectEqual(@as(usize, 1), count); // the +Errors that had no room is gone } + +test "size sets the screen of a session no frontend is attached to, and only of one" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120 40\n").errno()); // a frontend's + p.unattended = true; + try testing.expectEqual(Status.ok, wr(p, root_ctl, "size 120 40\n").reply.status); + try testing.expectEqual(@as(u16, 120), p.screen_w); + try testing.expectEqual(@as(u16, 40), p.screen_h); + try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno()); + try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 5 2\n").errno()); +} diff --git a/test/fs.py b/test/fs.py index 68da6c51..9336a53a 100644 --- a/test/fs.py +++ b/test/fs.py @@ -556,6 +556,12 @@ def cut_writes(binary): client.write(f'/pane/{serial}/ctl', block) body = client.read(f'/pane/{serial}/body').decode().splitlines() assert body == ['line %05d of the block' % i for i in range(2500)], (len(body), body[:2]) + # A detached session with no frontend is 80x24 until told. + screen = client.screen() + assert (screen['cols'], screen['rows']) == (80, 24), (screen['cols'], screen['rows']) + client.write('/ctl', b'size 132 50\n') + screen = client.screen() + assert (screen['cols'], screen['rows']) == (132, 50), (screen['cols'], screen['rows']) def test(binary, quic=False): -- cgit v1.3