From 3815a0bfad5818e2caf85a00c0c2d3aecc13bd1c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:58:54 -0300 Subject: pardes - reads stdin into a +Pager pane, and is a terminal's PAGER and GIT_PAGER, Pager pardes|off to opt out; command panes page through cat A paging command in a terminal, or run through its pty/run, sat in less, and a pty/run never answered. `pardes -` now reads stdin to its end, strips terminal escapes (colour, OSC, overstrike), and shows it: inside a session as a clean /+Pager pane made through pane/new and back at once, outside one as a new editor whose first pane it is, as `vim -` does. A terminal's shell gets PAGER and GIT_PAGER set to this pardes's own path and `-` where the user's environment sets neither; `Pager off`, a setting in /ctl, init and DumpConfig, leaves terminals started after it to the environment. A command pane's one-shot shell keeps cat, its output being a pane already. Co-Authored-By: Claude Opus 5.5 --- src/config.zig | 11 ++++- src/host_io.zig | 66 +++++++++++++++++++++++++---- src/main.zig | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/ninep/ctl.zig | 11 +++++ src/pardes.zig | 16 +++++++ 5 files changed, 214 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/config.zig b/src/config.zig index 86951e27..5a9cf070 100644 --- a/src/config.zig +++ b/src/config.zig @@ -81,6 +81,7 @@ pub const leader_path = paths: { .Placement = "cp", .BootShell = null, .LookWord = null, + .Pager = null, .Debug = "td", .Msg = null, .Colors = "tc", @@ -754,13 +755,18 @@ pub const Runtime = struct { /// next place in that pane, wrapping; `list`, what pardes did before, /// lists every place in a +Search pane (look.zig, lookWord). look_word: LookWord = .search, + /// What a terminal's shell pages through (host_io.ChildEnv): `pardes`, + /// PAGER and GIT_PAGER set to `pardes -`, a +Pager pane, where the + /// user's environment set neither; `off`, the environment as it is. + pager: Pager = .pardes, pub const ShaderAnimation = enum { off, on, always }; pub const Placement = enum { acme, pardes }; pub const BootShell = enum { keep, replace }; pub const LookWord = enum { search, list }; + pub const Pager = enum { pardes, off }; /// A setting that is one of a few words, each an enum field of Runtime. - pub const Choice = enum { placement, boot_shell, look_word }; + pub const Choice = enum { placement, boot_shell, look_word, pager }; /// shadow: a soft drop shadow. rim: a light hairline along the top of /// the focused pane's tag rows. auto: a shadow on a light page; on a @@ -1025,6 +1031,7 @@ pub const Runtime = struct { .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." }, .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." }, .{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." }, + .{ .word = "Pager", .action = .{ .choice = .pager }, .doc = "What a terminal's commands page through: pardes, a +Pager pane (pardes -), or off, the environment's own; for terminals started after it." }, }; pub fn find(name: []const u8) ?Setting { @@ -1699,6 +1706,7 @@ pub const Runtime = struct { .placement => "pardes", .boot_shell => "replace", .look_word => "list", + .pager => "off", }, .lift => "rim", .motion => "playful", @@ -1768,6 +1776,7 @@ pub const Runtime = struct { .placement => "Placement pardes\n", .boot_shell => "BootShell replace\n", .look_word => "LookWord list\n", + .pager => "Pager off\n", }, .lift => "Lift rim\n", .motion => "Motion playful\n", diff --git a/src/host_io.zig b/src/host_io.zig index 8ddb0c90..4ebac9ee 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1255,7 +1255,12 @@ pub const ChildEnv = struct { return line[0 .. std.mem.indexOfScalar(u8, line, '=') orelse line.len]; } - pub fn build(slots: *Slots) ?[*:null]const ?[*:0]const u8 { + /// Which pager a child is given where the user's environment names none: + /// a command pane's one-shot shell `cat` (its output is a pane already), + /// a terminal's shell `pardes -` (a +Pager pane) unless `Pager off`. + pub const Paging = enum { command, terminal, terminal_off }; + + pub fn build(slots: *Slots, paging: Paging) ?[*:null]const ?[*:0]const u8 { var n: usize = 0; var i: usize = 0; while (libc.environ[i]) |entry| : (i += 1) { @@ -1268,9 +1273,15 @@ pub const ChildEnv = struct { slots[n] = entry; n += 1; } - // No pager to wait in: a pty/run or a command pane's output is read - // whole, and less would hold it at its first screen for ever. Only - // where the inherited environment chose none. + // No pager to wait in (less holds a run at its first screen for + // ever): a command pane's output is read whole, through cat; a + // terminal's goes to a +Pager pane. Only where the inherited + // environment chose none. + const defaults: []const [*:0]const u8 = switch (paging) { + .command => &cat_pagers, + .terminal => pardesPagers(), + .terminal_off => &.{}, + }; for (defaults) |default| { const set = for (libc.environ[0..i]) |entry| { if (std.mem.eql(u8, name(entry.?), name(default))) break true; @@ -1288,8 +1299,27 @@ pub const ChildEnv = struct { return @ptrCast(slots); } - /// Set for a pane's child only when the user's environment has none. - const defaults = [_][*:0]const u8{ "PAGER=cat", "GIT_PAGER=cat" }; + const cat_pagers = [_][*:0]const u8{ "PAGER=cat", "GIT_PAGER=cat" }; + + /// `PAGER=' -'`, its own path so no PATH lookup can find + /// another; plain `pardes -` where the path cannot be read or quoted. + fn pardesPagers() []const [*:0]const u8 { + const S = struct { + var pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var git_pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var entries: [2][*:0]const u8 = undefined; + var ready = false; + }; + if (!S.ready) { + var exe_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = if (comptime builtin.os.tag == .linux) libc.readlink("/proc/self/exe", &exe_buf, exe_buf.len) else -1; + const exe: []const u8 = if (n > 0 and @as(usize, @intCast(n)) < exe_buf.len and std.mem.indexOfScalar(u8, exe_buf[0..@intCast(n)], '\'') == null) exe_buf[0..@intCast(n)] else "pardes"; + S.entries[0] = if (std.fmt.bufPrintSentinel(&S.pager, "PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "PAGER=pardes -"; + S.entries[1] = if (std.fmt.bufPrintSentinel(&S.git_pager, "GIT_PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "GIT_PAGER=pardes -"; + S.ready = true; + } + return &S.entries; + } }; test "the child environment replaces the launcher's terminal identity exactly once" { @@ -1314,7 +1344,24 @@ test "the child environment replaces the launcher's terminal identity exactly on defer _ = unsetenv("PARDES_CHILD_ENV_PROBE"); var slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&slots) orelse return error.EnvironmentTooLarge; + // Where the user named no pager: a terminal's shell pages into a + // +Pager pane (`pardes -`), or, Pager off, as the environment says; a + // command pane's through cat. A pager the user named is kept. + for ([_]ChildEnv.Paging{ .terminal, .terminal_off }) |paging| { + var shell_slots: ChildEnv.Slots = undefined; + const shell_envp = ChildEnv.build(&shell_slots, paging) orelse return error.EnvironmentTooLarge; + var said: usize = 0; + var j: usize = 0; + while (shell_envp[j]) |entry| : (j += 1) { + const line = std.mem.span(entry); + if (std.mem.startsWith(u8, line, "PAGER=") and std.c.getenv("PAGER") == null) { + try std.testing.expect(paging == .terminal and std.mem.endsWith(u8, line, " -")); + said += 1; + } + } + if (std.c.getenv("PAGER") == null) try std.testing.expectEqual(@as(usize, if (paging == .terminal) 1 else 0), said); + } + const envp = ChildEnv.build(&slots, .command) orelse return error.EnvironmentTooLarge; var terms: usize = 0; var colorterms: usize = 0; var probes: usize = 0; @@ -1442,7 +1489,10 @@ pub fn forkShell( // not call setenv, whose malloc can deadlock against a pty reader thread // that held the heap when the fork took its snapshot. var env_slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&env_slots); + const paging: ChildEnv.Paging = if (one_line != null) + .command + else if (core) |c| (if (c.settings.pager == .off) .terminal_off else .terminal) else .terminal; + const envp = ChildEnv.build(&env_slots, paging); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; // The child says here why it could not become the shell (its chdir or // its exec failed, as for a script whose interpreter is not there): the diff --git a/src/main.zig b/src/main.zig index 9dce559f..8818645e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -46,11 +46,13 @@ pub const debug = if (is_emscripten) struct {} else struct { }; const help_text = - \\Usage: pardes [options] [file|dir] + \\Usage: pardes [options] [file|dir|-] \\ \\With no arguments the shell pane starts in tty mode. A FILE argument \\(optionally file:LINE) opens it alone, filling the window; a DIRECTORY - \\argument becomes the cwd shells spawn in. + \\argument becomes the cwd shells spawn in. `-` reads stdin, its terminal + \\escapes stripped, into a +Pager pane: inside a pardes pane the outer + \\session's, returning at once (a terminal's PAGER), else a new editor's. \\ \\Options: \\ --tty start as one shell pane already in tty mode @@ -98,6 +100,95 @@ const help_text = \\ ; +/// Stdin to its end, its terminal escapes stripped (a pager's input is +/// coloured for a terminal; a text pane shows text), at most a file's +/// limit, a longer one cut there and said so at its end. +fn readStdinText(gpa: std.mem.Allocator) ![]u8 { + const limit = pardes.memory.limits.max_file_bytes; + var raw: std.ArrayList(u8) = .empty; + var chunk: [64 * 1024]u8 = undefined; + var cut = false; + while (true) { + const n = std.c.read(0, &chunk, chunk.len); + if (n < 0) { + if (std.posix.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + const got: usize = @intCast(n); + if (raw.items.len + got > limit) { + try raw.appendSlice(gpa, chunk[0 .. limit - raw.items.len]); + cut = true; + break; + } + try raw.appendSlice(gpa, chunk[0..got]); + } + var text = try stripEscapes(gpa, raw.items); + if (cut) { + const said = "\n[pardes: the rest of stdin is cut: a pane holds at most a file's limit]\n"; + text = try std.mem.concat(gpa, u8, &.{ text, said }); + } + return text; +} + +/// `bytes` without terminal escapes: CSI (`ESC [` ... a final byte, SGR +/// colour among them), OSC (`ESC ]` ... BEL or `ESC \`), any other +/// two-byte `ESC x`, and a man page's overstrike (`x BS x`, `_ BS x`). +pub fn stripEscapes(gpa: std.mem.Allocator, bytes: []const u8) ![]u8 { + var out = try std.ArrayList(u8).initCapacity(gpa, bytes.len); + var i: usize = 0; + while (i < bytes.len) { + const c = bytes[i]; + if (c == 0x1b and i + 1 < bytes.len) { + const kind = bytes[i + 1]; + i += 2; + if (kind == '[') { + while (i < bytes.len and !(bytes[i] >= 0x40 and bytes[i] <= 0x7e)) i += 1; + i += 1; + } else if (std.mem.indexOfScalar(u8, "()*+", kind) != null) { + i += 1; // a charset's name after it + } else if (kind == ']') { + while (i < bytes.len) : (i += 1) { + if (bytes[i] == 0x07) { + i += 1; + break; + } + if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '\\') { + i += 2; + break; + } + } + } + continue; + } + // Overstrike: the byte before a backspace is struck over. + if (c == 0x08) { + if (out.items.len > 0) _ = out.pop(); + i += 1; + continue; + } + out.appendAssumeCapacity(c); + i += 1; + } + return out.toOwnedSlice(gpa); +} + +test "stripEscapes takes out colour, other escapes and overstrike, and keeps the text" { + const gpa = std.testing.allocator; + const cases = [_][2][]const u8{ + .{ "\x1b[33mcommit abc\x1b[m\n", "commit abc\n" }, + .{ "a\x1b]8;;http://x\x1b\\link\x1b]8;;\x07b", "alinkb" }, + .{ "N\x08NA\x08AM\x08ME\x08E _\x08u", "NAME u" }, + .{ "plain\ttext\n", "plain\ttext\n" }, + .{ "\x1b(Bdone", "done" }, + }; + for (cases) |c| { + const got = try stripEscapes(gpa, c[0]); + defer gpa.free(got); + try std.testing.expectEqualStrings(c[1], got); + } +} + /// A forwarding failure in words, never an error's name: the ones this /// file raises said outright, any other as its name's words (`NotFound`, /// `not found`), a dial's as Messages says it. @@ -369,7 +460,8 @@ fn nativeMain(init: std.process.Init) !void { } else if (std.mem.eql(u8, a, "--version")) { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; - } else if (a.len > 0 and a[0] != '-' and positional == null) { + } else if (a.len > 0 and (a[0] != '-' or std.mem.eql(u8, a, "-")) and positional == null) { + // `-` is stdin, as for cat and vim: a text to show. session_only = false; positional = a; } else if (a.len == 0) { @@ -440,6 +532,24 @@ fn nativeMain(init: std.process.Init) !void { // itself, which is what a right click in that pane would have been. var look_buf: [64]u8 = undefined; const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial}); + // `pardes -`: stdin read to its end, a +Pager pane of the session + // made of it (as acme's `B` has no stdin, through pane/new, a name + // and the body), and back at once: the PAGER a pane's shell gets. + if (positional) |word| if (std.mem.eql(u8, word, "-")) { + const text = readStdinText(arena) catch |err| Refuse.with(init.io, "stdin", err); + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, "-", err); + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, "-", err); + var cwd_buf: [4096]u8 = undefined; + const cwd = if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]u8, @ptrCast(c))) else "/"; + var path_buf: [64]u8 = undefined; + var line_buf: [4200]u8 = undefined; + const name_line = std.fmt.bufPrint(&line_buf, "{s}/+Pager\n", .{std.mem.trimEnd(u8, cwd, "/")}) catch "/+Pager\n"; + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/name", .{serial}), name_line) catch |err| Refuse.with(init.io, "-", err); + if (text.len > 0) ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/body", .{serial}), text) catch |err| Refuse.with(init.io, "-", err); + // Shown, not edited: nothing to ask about at Exit. + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/ctl", .{serial}), "clean\n") catch {}; + return; + }; const typed_word = positional orelse { var tag_buf: [64]u8 = undefined; const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); @@ -530,6 +640,12 @@ fn nativeMain(init: std.process.Init) !void { if (wait) waitForDel(init.io, arena, parent.dial, path, made_serial); return; } + // `pardes -` outside a session: an editor whose first pane holds stdin, + // as `vim -` does; the terminal is /dev/tty, never stdin. + if (positional) |a| if (std.mem.eql(u8, a, "-")) { + opts.stdin_text = readStdinText(arena) catch |err| badArgs(init.io, "stdin: {s}", .{@errorName(err)}); + positional = null; + }; if (positional) |a| { const target = @import("look.zig").parsePathLine(a); if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index e0c4b21b..87339b70 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1812,6 +1812,17 @@ test "a look at a file it may not read fails the write naming the file" { try testing.expectEqualStrings(expected, refused.reply.ename); } +test "Pager is a setting of the root ctl: pardes by default, off taken, anything else refused with its choices" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager pardes\n") != null); + try testing.expectEqual(Status.ok, wr(p, root_ctl, "Pager off\n").reply.status); + try testing.expectEqual(config.Runtime.Pager.off, p.settings.pager); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager off\n") != null); + try testing.expectEqualStrings("bad value in control message; takes pardes, off \"Pager\"", wr(p, root_ctl, "Pager less\n").reply.ename); +} + test "a refused Del written back through event fails that write, EIO, with its err" { const gpa = testing.allocator; const p = try withFile(gpa, "text\n"); diff --git a/src/pardes.zig b/src/pardes.zig index 47ebf8fa..aa2c252c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4180,6 +4180,8 @@ pub const Boot = enum { document, /// a Look that found nothing: reported, not opened missing, + /// `pardes -`: stdin's text in a +Pager pane, as `vim -` shows it + stdin, /// the firmware's boot buffer, the only pane a board has embedded, /// the bare tty startup: one shell in raw tty mode, with an empty text @@ -4196,6 +4198,7 @@ pub const Boot = enum { if (opts.boot) |named| return named; if (opts.file != null) return .document; if (opts.missing != null) return .missing; + if (opts.stdin_text != null) return .stdin; if (comptime platform == .esp32p4) return .embedded; if (opts.tty_only) return .tty_shell; // ponytail: 1 and 3 are the only shell counts anyone asked for @@ -4215,6 +4218,8 @@ pub const Options = struct { file: ?[]const u8 = null, file_line: usize = 0, missing: ?struct { word: []const u8, dir: []const u8 } = null, + /// `pardes -`: stdin, its escapes stripped, for a +Pager pane at boot. + stdin_text: ?[]const u8 = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, @@ -4626,6 +4631,17 @@ pub const Pardes = struct { _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.oneColumn(); }, + .stdin => { + const content = try gpa.dupe(u8, opts.stdin_text.?); + errdefer gpa.free(content); + const dir = p.sessionDir(); + const shown = try panes.Output.open(p, 0, dir, .{ .cmd = .New }, "", content); + // Named +Pager, as `pardes -` inside a session names its pane. + const named = try std.fs.path.join(gpa, &.{ dir, "+Pager" }); + gpa.free(shown.file.?.path); + shown.file.?.path = named; + p.oneColumn(); + }, .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); -- cgit v1.3