From 78ee8672307adbdf30a2afffc38efb70217dc212 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 20:35:50 -0300 Subject: pty/status reads the pty's size as winsize set it, on a line of its own A dogfood agent wrote winsize 100 30 to pty/ctl and pty/status went on reporting the pane's grid, and the status line had no newline to end it. The size winsize sets is kept until the pane resizes and gives the pty its grid again; ctl's winsize read back and status both report it, and status ends with a newline. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 3 ++- docs/fs.md | 6 +++++- src/ninep/pane.zig | 3 +++ src/ninep/pty.zig | 31 +++++++++++++++++++++++-------- src/pardes.zig | 1 + 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 1be910c1..471967ad 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -313,7 +313,8 @@ Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not command. For an interactive application, send its actual input bytes; `b'\x03'` is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`, -`sig INT` and `exec`, one per line. +`sig INT` and `exec`, one per line. `pty/status` reads one line: the pty's cols, +rows and busy (1 while a command runs or text is typed at the prompt). `client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major `cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve diff --git a/docs/fs.md b/docs/fs.md index a489e1ea..29f15571 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -563,7 +563,11 @@ command whose end mark carried no status, which is not a success; `error out of memory` is an answer that could not be made. The header is always the whole first line. It reads `busy` at once when a command is running or text is typed at the prompt, -which is also when the third field of `pty/status` reads 1. A line written +which is also when the third field of `pty/status` reads 1. `pty/status` is +one line, three right-aligned fields and a newline: the pty's columns and +rows, then busy (0 or 1). The size, and `pty/ctl`'s `winsize` read back, is +what `winsize C R` last set, until the pane itself resizes and gives the pty +its grid again. A line written before a new terminal's shell has drawn its first prompt is not busy: it waits for that prompt (a respawn meanwhile keeps it waiting for the new shell's) and is sent then, so the first command a script gives a fresh diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 50fc220e..ad6a6a28 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -63,6 +63,9 @@ pub const State = struct { lock: ?u32 = null, /// The host started a shell it could not teach to mark its prompts. unmarked: bool = false, + /// The pty's size as pty/ctl's winsize last set it, until the pane's own + /// grid resizes and gives the pty its size again. + winsize: ?[2]u16 = null, /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 7a5187fe..37132f90 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -60,6 +60,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { if (words.next() != null) return false; if (!apply) return true; p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + pane.fs.winsize = .{ cols, rows }; }, .sig => { const which = signalNamed(words.next() orelse return false) orelse return false; @@ -79,13 +80,20 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { /// ctl reads back its state in the words it takes, as a Plan 9 ctl does. pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch + const size = winsize(pane); + p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ size[0], size[1] }) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } -/// Three right-aligned fields: cols, rows and busy: whether a run written now -/// would be told `busy` (a command runs, or text is typed at the prompt). +/// The pty's size: what ctl's winsize set, else the pane's grid. +fn winsize(pane: *const Pane) [2]u16 { + return pane.fs.winsize orelse .{ pane.cols, pane.rows }; +} + +/// Three right-aligned fields: the pty's cols and rows, and busy: whether a +/// run written now would be told `busy` (a command runs, or text is typed at +/// the prompt). A newline ends the line, as a Plan 9 status file's does. pub const status_len: u64 = 36; pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { @@ -97,7 +105,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, @intFromBool(busy) }) catch {}; + const size = winsize(pane); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {}; return tree.stagedReply(p, req); } @@ -466,6 +475,12 @@ test "every pty/ctl verb, and every refusal" { try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); try testing.expectEqual(cols, pane.cols); try testing.expectEqual(rows, pane.rows); + // ctl and status read back the pty's size, until the pane resizes. + try testing.expectEqualStrings("winsize 132 44\n", rd(p, ctl, 0, 64).bytes); + try testing.expect(std.mem.startsWith(u8, rd(p, Node.of(serialOf(p), .pty_status), 0, 64).bytes, " 132 44 ")); + p.update(.{ .resize = .{ .cols = 76, .rows = 24 } }); + try testing.expect(pane.cols != 132); + try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ pane.cols, pane.rows }), rd(p, ctl, 0, 64).bytes); for ([_]struct { line: []const u8, want: pardes.PtySignal }{ .{ .line = "sig INT", .want = .int }, @@ -556,7 +571,7 @@ test "pty/status reports the grid and who holds the tty" { const a = rd(p, status, 0, 256); try testing.expectEqual(Status.ok, a.reply.status); var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 0 }); try testing.expectEqualStrings(whole, a.bytes); try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); @@ -565,7 +580,7 @@ test "pty/status reports the grid and who holds the tty" { p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable }; const held = rd(p, status, 0, 256); try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 1 }), held.bytes, ); } @@ -647,12 +662,12 @@ test "run answers how its command ended once the shell is back at a prompt" { // Before its first prompt the shell cannot take a line yet: the line // waits for it, and pty/status does not call the shell busy meanwhile. const status = Node.of(serialOf(p), .pty_status); - try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0 ")); + try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0\n")); const early = call(p, .{ .tag = 1, .op = .open, .node = node }); const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" }); try testing.expectEqualStrings("", waiting.pty()); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); - try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1 ")); + try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1\n")); // A respawn before that prompt keeps it waiting for the new shell's. p.acknowledgeShell(0, "/bin/sh", true); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); diff --git a/src/pardes.zig b/src/pardes.zig index 992311a2..a3436870 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -6186,6 +6186,7 @@ pub const Pardes = struct { panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + pane.fs.winsize = null; } pane.cols = cols; pane.rows = rows; -- cgit v1.3