diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p.zig | 18 | ||||
| -rw-r--r-- | src/builtins.zig | 8 | ||||
| -rw-r--r-- | src/config.zig | 8 | ||||
| -rw-r--r-- | src/fs-help.txt | 65 | ||||
| -rw-r--r-- | src/fs.zig | 64 | ||||
| -rw-r--r-- | src/host_io.zig | 16 | ||||
| -rw-r--r-- | src/linux/v9fs.zig | 201 | ||||
| -rw-r--r-- | src/main.zig | 2 | ||||
| -rw-r--r-- | src/panes.zig | 42 | ||||
| -rw-r--r-- | src/pardes.zig | 178 | ||||
| -rw-r--r-- | src/tutor.txt | 61 |
11 files changed, 535 insertions, 128 deletions
@@ -511,7 +511,11 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type { if (st.type != std.math.maxInt(u16) or st.dev != std.math.maxInt(u32) or st.qid.type != std.math.maxInt(u8) or st.qid.version != std.math.maxInt(u32) or st.qid.path != std.math.maxInt(u64) or st.mode != std.math.maxInt(u32) or - st.atime != std.math.maxInt(u32) or st.mtime != std.math.maxInt(u32) or + st.atime != std.math.maxInt(u32) or + // Linux v9fs follows O_TRUNC with Twstat(length=0, mtime=now). + // Accept that timestamp hint with truncation; this control + // filesystem does not persist caller-selected timestamps. + (st.mtime != std.math.maxInt(u32) and st.length != 0) or st.name.len != 0 or st.uid.len != 0 or st.gid.len != 0 or st.muid.len != 0) return s.fail(tag, e_wstat); if (st.length == std.math.maxInt(u64)) { @@ -738,6 +742,7 @@ pub fn Server(comptime fs: type, comptime fid_capacity: usize) type { return; } const f = s.jobFid() orelse return; + if (r.attr.node != 0) f.node = r.attr.node; f.open = true; f.omode = j.omode; f.handle = r.handle; @@ -1729,6 +1734,7 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { changes[11].muid = "writer"; for (changes) |change| { for ([_]u64{ std.math.maxInt(u64), 0 }) |length| { + if (change.mtime != std.math.maxInt(u32) and length == 0) continue; var attributes = change; attributes.length = length; const calls = h.fsys.calls; @@ -1741,6 +1747,16 @@ test "9p server: Twstat with a zero length is the truncate, and so is OTRUNC" { } } + // Captured Linux v9fs O_TRUNC follow-up: length=0 plus current mtime. + var linux_truncate = sentinel; + linux_truncate.length = 0; + linux_truncate.mtime = 1789432552; + try h.send(21, .{ .twstat = .{ .fid = 1, .stat = linux_truncate } }); + got = try h.reap(); + try testing.expect(got.msg == .rwstat); + try testing.expectEqualStrings("", h.fsys.body); + h.fsys.body = "hello, body\n"; + var zero = sentinel; zero.length = 0; try h.send(9, .{ .twstat = .{ .fid = 1, .stat = zero } }); diff --git a/src/builtins.zig b/src/builtins.zig index a9728fd9..c483482a 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -465,6 +465,14 @@ pub const Tty = struct { } }; +/// A Linux terminal with the current session mounted through kernel v9fs. +pub const Tty9p = struct { + pub const enabled = pardes.hosted and @import("builtin").os.tag == .linux; + pub fn run(c: Ctx) void { + c.p.spawnV9fsTty(c.id); + } +}; + /// Fold the active pane's column into the one on its right, keeping its panes. /// The horizontal mirror of the vertical stacking `New` does. pub const Joincol = struct { diff --git a/src/config.zig b/src/config.zig index 865e2474..5e835fa0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -118,6 +118,7 @@ pub const leader_path = paths: { table.set(.Glitch, "tg"); } if (builtins.EffectCode.enabled) table.set(.EffectCode, null); + if (builtins.Tty9p.enabled) table.set(.Tty9p, "n9"); if (pardes.hosted) { table.set(.ThemeFile, null); table.set(.DumpThemes, null); @@ -209,13 +210,6 @@ pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; pub const tty_toggle_default: u21 = 'b'; pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; -// Test the shifted/system-clipboard chord first; unrequested shift is ignored. -pub const tty_paste: []const Chord = &.{.{ .cp = 'v', .ctrl = true }}; -pub const tty_paste_clipboard: []const Chord = &.{ - .{ .cp = 'v', .ctrl = true, .shift = true }, - .{ .cp = 'V', .ctrl = true }, -}; - // OSC 133 prompt cells are hidden in normal mode; input columns stay intact. pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; diff --git a/src/fs-help.txt b/src/fs-help.txt new file mode 100644 index 00000000..eeb000cc --- /dev/null +++ b/src/fs-help.txt @@ -0,0 +1,65 @@ +Pardes control filesystem +========================= +The mount root contains self/ (this editor) and os/ (host files). +This guide is available as self/README and self/new/README. +Examples below run inside a Tty9p shell, where PARDES_MOUNT is set. + +Explore +------- + ls "$PARDES_MOUNT/self" + cat "$PARDES_MOUNT/self/index" + ls -l "$PARDES_MOUNT/self/new" + ls "$PARDES_MOUNT/self/pane" + +index lists panes: serial, tag length, body length, reserved zero, +modified flag, then tag text. Use the serial in self/pane/<serial>/. +screen is rendered screen JSON; listeners lists the session addresses. +cons accepts text to display in the editor. + +Create a pane +------------- +new/ lists the same files as a scratch pane, plus this README. +Listing, walking and statting these paths do not create anything. +OPENING any file except README creates one scratch pane below the active pane. +Each open creates a separate pane, even if you open the same path again. + + cat "$PARDES_MOUNT/self/new/ctl" + +This creates a pane and prints its control information, beginning with its +serial. Use self/pane/<serial>/ for all subsequent operations on that pane. +To create a pane and write its initial text in one operation: + + printf 'Hello from the shell\n' > "$PARDES_MOUNT/self/new/body" + +Reading all files recursively would open factories and create panes. +Use ls or stat to inspect them; read README for this guide. + +Work with an existing pane +-------------------------- +Replace <serial> with a number from index or new/ctl: + + cat "$PARDES_MOUNT/self/pane/<serial>/body" + printf 'Replacement text\n' > "$PARDES_MOUNT/self/pane/<serial>/body" + printf 'More text\n' >> "$PARDES_MOUNT/self/pane/<serial>/body" + printf 'name notes.txt\n' > "$PARDES_MOUNT/self/pane/<serial>/ctl" + +body buffer text; for terminals, reads history and writes child input + tag editable pane tag + ctl control information on read; newline-separated commands on write + addr address range; write #0,#5 to select the first five bytes + data read from addr onward, or replace the range selected by addr + xdata read only the range selected by addr; writes replace that range + rdsel read the editor selection + wrsel replace the editor selection + errors write diagnostics + event pane interaction events; opening intercepts Look/Exec events + +Common ctl commands: name PATH, put (save), get (reload), dot=addr, +addr=dot, look PATH, del (close, refusing dirty text), delete (force close). +Reload and force close can discard unsaved changes. + +Terminal panes also expose pty/ctl, pty/status and pty/data. +Writing pty/data sends terminal input; use carriage return to submit a command. +Reading pty/data streams new output; read body for a history snapshot. + +Embedded source files under self/src/ document the complete interface. @@ -1331,6 +1331,7 @@ pub const namespace_panes: u64 = namespace_root + 1; pub const os_root: u64 = namespace_root + 2; pub const os_node: u64 = 1 << 62; const factory_base: u64 = namespace_root + 256; +const fs_help = @embedFile("fs-help.txt"); pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { var path_buf: [4096]u8 = undefined; @@ -1341,6 +1342,7 @@ pub fn resolveSelf(p: *Pardes, path: []const u8) ?u64 { const top = topFileNamed(first) orelse return archiveNode(normalized); if (parts.next()) |name| { if (top != .new or parts.next() != null) return null; + if (std.mem.eql(u8, name, "README")) return @intFromEnum(SelfFile.README); const file = paneFileNamed(name) orelse return null; if (file.inPty()) return null; return factory_base + @intFromEnum(file); @@ -1436,7 +1438,7 @@ pub const Req = struct { pub fn changesPane(req: Req) bool { return switch (req.op) { .write, .setattr => true, - .lookup => req.node == @intFromEnum(SelfFile.new) and !std.mem.eql(u8, req.data, ".."), + .lookup => false, .open => req.node >= factory_base and req.node < factory_base + 16, .getattr, .read, .release, .readdir => false, }; @@ -1538,6 +1540,7 @@ pub const SelfFile = enum(u4) { new = 4, screen = 5, listeners = 6, + README = 7, pub fn name(f: SelfFile) []const u8 { return if (f == .root) "." else @tagName(f); @@ -1546,7 +1549,7 @@ pub const SelfFile = enum(u4) { pub fn mode(f: SelfFile) u16 { return switch (f) { .root, .new => 0o500, - .index, .screen, .listeners => 0o400, + .index, .screen, .listeners, .README => 0o400, .cons => 0o200, }; } @@ -1891,7 +1894,10 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void { pub fn handle(p: *Pardes, req: Req) Reply { if (req.node >= factory_base and req.node < factory_base + 16) { const file = std.enums.fromInt(PaneFile, req.node - factory_base) orelse return Reply.fail(req.tag, E.NOENT); - if (req.op == .getattr) return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; + if (file == .dir or file.inPty()) return Reply.fail(req.tag, E.NOENT); + // Truncating a factory is harmless: its next open creates an empty pane. + if (req.op == .getattr or (req.op == .setattr and req.truncate)) + return .{ .tag = req.tag, .attr = .{ .node = req.node, .name = file.name(), .mode = file.mode() } }; if (req.op != .open) return Reply.fail(req.tag, E.PERM); const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); const node = Node.of(serial, file); @@ -1951,6 +1957,7 @@ fn topSize(p: *Pardes, f: SelfFile) u64 { return switch (f) { .root, .new, .cons, .screen, .listeners => 0, .index => indexLen(p), + .README => fs_help.len, }; } @@ -2132,10 +2139,10 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply { return Reply.fail(req.tag, E.NOENT); }, .new => new: { + if (std.mem.eql(u8, name, "README")) break :new @intFromEnum(SelfFile.README); const want = paneFileNamed(name) orelse return Reply.fail(req.tag, E.NOENT); if (want.inPty()) return Reply.fail(req.tag, E.NOENT); - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - break :new Node.of(serial, want); + return handle(p, .{ .tag = req.tag, .op = .getattr, .node = factory_base + @intFromEnum(want) }); }, else => return Reply.fail(req.tag, E.NOTDIR), }, @@ -2194,9 +2201,16 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, namespace_panes, true, "pane"); if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.screen), false, "screen"); if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.listeners), false, "listeners"); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README"); stageArchive(p, out, "", &skip); }, - .new => {}, + .new => { + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(SelfFile.README), false, "README"); + inline for (comptime std.enums.values(PaneFile)) |file| { + if (comptime file == .dir or file.inPty()) continue; + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, factory_base + @intFromEnum(file), false, file.name()); + } + }, else => return Reply.fail(req.tag, E.NOTDIR), }, .pane => |t| { @@ -2320,6 +2334,12 @@ fn handleRead(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .index => readIndex(p, req), + .README => help: { + const off = @min(req.off, fs_help.len); + const bytes = fs_help[off..][0..@min(req.size, fs_help.len - off)]; + p.fs.stage(p.gpa).appendSlice(p.gpa, bytes) catch break :help Reply.fail(req.tag, E.NOMEM); + break :help .{ .tag = req.tag, .payload = .{ .staged = @intCast(bytes.len) } }; + }, .listeners => listeners: { var buf: [512]u8 = undefined; var text = std.Io.Writer.fixed(&buf); @@ -3233,6 +3253,7 @@ test "filesystem inspection preserves pending and displayed Look hover" { const requests = [_]Req{ .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.root), .data = "screen" }, .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = ".." }, + .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, .{ .tag = 2, .op = .getattr, .node = body }, .{ .tag = 3, .op = .open, .node = body }, .{ .tag = 4, .op = .read, .node = body, .size = 5 }, @@ -3258,7 +3279,6 @@ test "filesystem inspection preserves pending and displayed Look hover" { test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ - .{ .tag = 1, .op = .lookup, .node = @intFromEnum(SelfFile.new), .data = "body" }, .{ .tag = 2, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }, .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, }; @@ -3659,7 +3679,15 @@ test "readdir lists the root, a pane directory, and new/ without creating anythi const before = p.next_serial; const new = rdir(p, @intFromEnum(SelfFile.new), 0); try testing.expectEqual(Status.ok, new.reply.status); - try testing.expectEqual(@as(usize, 0), new.bytes.len); + const factories = dirents(new.bytes, &buf); + try testing.expectEqual(@as(usize, 11), factories.len); + try testing.expect(nameAt(factories, "README") != null); + for (factories) |entry| { + const inspected = look_up(p, @intFromEnum(SelfFile.new), entry.name); + try testing.expectEqual(Status.ok, inspected.reply.status); + try testing.expectEqual(entry.node, inspected.reply.attr.node); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = entry.node }).reply.status); + } try testing.expectEqual(before, p.next_serial); try testing.expectEqual(E.NOTDIR, rdir(p, Node.of(serial, .body), 0).errno()); @@ -3693,7 +3721,7 @@ test "lookup resolves top files, pane serials and pane files" { try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); } -test "a lookup inside new/ creates a pane and resolves that pane's file" { +test "new/ lookup is inert and each open creates a distinct pane" { const gpa = testing.allocator; const p = try withFile(gpa, "first\n"); defer p.deinit(); @@ -3704,11 +3732,17 @@ test "a lookup inside new/ creates a pane and resolves that pane's file" { const a = look_up(p, @intFromEnum(SelfFile.new), "body"); try testing.expectEqual(Status.ok, a.reply.status); - const made: Node = @bitCast(a.reply.attr.node); + try testing.expectEqual(before, p.next_serial); + const opened = call(p, .{ .tag = 1, .op = .open, .node = a.reply.attr.node }); + try testing.expectEqual(Status.ok, opened.reply.status); + const made: Node = @bitCast(opened.reply.attr.node); try testing.expect(made.serial != before); try testing.expectEqual(@intFromEnum(PaneFile.body), made.file); - _ = wr(p, a.reply.attr.node, "hi"); + _ = wr(p, opened.reply.attr.node, "hi"); + const second = call(p, .{ .tag = 2, .op = .open, .node = a.reply.attr.node }); + try testing.expectEqual(Status.ok, second.reply.status); + try testing.expect(second.reply.attr.node != opened.reply.attr.node); const id = p.paneBySerial(@intCast(made.serial)).?; try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); } @@ -4055,7 +4089,7 @@ test "ctl name promotes a scratch without changing its body or undo history" { const gpa = testing.allocator; const p = try withFile(gpa, "opener\n"); defer p.deinit(); - _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) }); const pane = p.panes[p.active].?; const ctl = Node.of(pane.serial, .ctl); const body = Node.of(pane.serial, .body); @@ -4133,7 +4167,7 @@ test "ctl relative names follow inherited scratch and virtual directories" { defer p.deinit(); const source = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .ctl), "name /project/src/source.zig\n").reply.status); - _ = look_up(p, @intFromEnum(SelfFile.new), "ctl"); + _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.ctl) }); const scratch = p.panes[p.active].?; const ctl = Node.of(scratch.serial, .ctl); try testing.expectEqualStrings("/project/src", Pardes.paneDir(scratch)); @@ -4176,7 +4210,7 @@ test "ctl get reloads the pane from disk and del honours a dirty body" { _ = wr(p, ctl, "dirty\n"); try testing.expectEqual(E.INVAL, wr(p, ctl, "del\n").errno()); try testing.expect(p.paneBySerial(serial) != null); - _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); try testing.expectEqual(Status.ok, wr(p, ctl, "delete\n").reply.status); try testing.expect(p.paneBySerial(serial) == null); } @@ -4396,7 +4430,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" defer p.deinit(); const serial = serialOf(p); const event = Node.of(serial, .event); - _ = look_up(p, @intFromEnum(SelfFile.new), "body"); + _ = call(p, .{ .tag = 1, .op = .open, .node = factory_base + @intFromEnum(PaneFile.body) }); const a = call(p, .{ .tag = 18, .op = .open, .node = event }); const b = call(p, .{ .tag = 19, .op = .open, .node = event }); diff --git a/src/host_io.zig b/src/host_io.zig index 000d1a88..212c23b4 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -867,11 +867,27 @@ pub fn forkShell( var master: c_int = -1; var path_buf: [std.fs.max_path_bytes]u8 = undefined; const spawn = Shell.resolve(bin, &path_buf, prompt_rcs); + var helper_buf: [4096]u8 = undefined; + const mounted = if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false; + const helper: ?[:0]u8 = if (mounted) blk: { + if (comptime builtin.os.tag != .linux) return error.LinuxRequired; + if (fs == null) return error.SessionSocketRequired; + break :blk try @import("linux/v9fs.zig").helperPath(&helper_buf); + } else null; ninep_io.exportPaneEnv( fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, if (core) |c| !c.opts.nested else false, ); + if (helper) |path| { + const c = core.?; + const pn = c.panes[pane].?; + const socket = libc.getenv("PARDES_9P") orelse return error.SessionSocketRequired; + var command: std.Io.Writer.Allocating = .init(c.gpa); + defer command.deinit(); + try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv); + if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; + } const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid < 0) return error.ForkFailed; diff --git a/src/linux/v9fs.zig b/src/linux/v9fs.zig new file mode 100644 index 00000000..4e18660d --- /dev/null +++ b/src/linux/v9fs.zig @@ -0,0 +1,201 @@ +//! Linux kernel-mount launcher. Installed as an ordinary executable beside +//! Pardes; sudo authorizes each launch. Never install setuid. +const std = @import("std"); +const builtin = @import("builtin"); + +extern "c" fn unshare(flags: c_int) c_int; +extern "c" fn mount(source: ?[*:0]const u8, target: [*:0]const u8, filesystemtype: ?[*:0]const u8, flags: c_ulong, data: ?*const anyopaque) c_int; +extern "c" fn getuid() c_uint; +extern "c" fn geteuid() c_uint; +extern "c" fn setgroups(size: usize, list: ?[*]const c_uint) c_int; +extern "c" fn initgroups(user: [*:0]const u8, group: c_uint) c_int; +extern "c" fn setresgid(real: c_uint, effective: c_uint, saved: c_uint) c_int; +extern "c" fn setresuid(real: c_uint, effective: c_uint, saved: c_uint) c_int; +extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn execvp(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, size: usize) isize; +extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; +extern "c" fn rmdir(path: [*:0]const u8) c_int; +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +extern "c" fn unsetenv(name: [*:0]const u8) c_int; +extern "c" fn fork() c_int; +extern "c" fn waitpid(pid: c_int, status: *c_int, flags: c_int) c_int; +extern "c" fn kill(pid: c_int, sig: c_int) c_int; +extern "c" fn _exit(status: c_int) noreturn; + +pub const executable = "pardes-v9fs"; + +/// One command for the normal interactive shell's startup queue. Quote every +/// argument so paths and shell setup strings remain data in bash, fish and sh. +pub fn writeLaunchCommand(writer: *std.Io.Writer, helper: []const u8, socket: []const u8, shell_argv: []const ?[*:0]const u8) !void { + try quote(writer, helper); + try writer.writeAll(" --launch "); + try quote(writer, socket); + try writer.writeAll(" --"); + for (shell_argv) |maybe| { + const arg = maybe orelse break; + try writer.writeByte(' '); + try quote(writer, std.mem.span(arg)); + } +} + +fn quote(writer: *std.Io.Writer, value: []const u8) !void { + // A literal newline would submit the interactive command before it is + // complete. These are paths/setup arguments, not arbitrary shell input. + if (std.mem.indexOfAny(u8, value, "\r\n") != null) return error.MultilineLaunchArgument; + try writer.writeByte('\''); + for (value) |byte| { + if (byte == '\'') try writer.writeAll("'\\''") else try writer.writeByte(byte); + } + try writer.writeByte('\''); +} + +fn selfPath(buf: []u8) ![:0]u8 { + const n = readlink("/proc/self/exe", buf.ptr, buf.len - 1); + if (n < 0 or n >= buf.len - 1) return error.ExecutablePathUnavailable; + const len: usize = @intCast(n); + buf[len] = 0; + return buf[0..len :0]; +} + +/// Resolve before fork, without relying on the shell's PATH. The override is +/// useful for build-cache binaries whose helper is in a different directory. +pub fn helperPath(buf: []u8) ![:0]u8 { + const path = if (std.c.getenv("PARDES_V9FS_HELPER")) |env| blk: { + const override = std.mem.span(env); + if (!std.fs.path.isAbsolute(override)) return error.AbsoluteHelperPathRequired; + break :blk try std.fmt.bufPrintZ(buf, "{s}", .{override}); + } else blk: { + var own: [4096]u8 = undefined; + const parent = std.fs.path.dirname(try selfPath(&own)) orelse return error.ExecutablePathUnavailable; + break :blk try std.fmt.bufPrintZ(buf, "{s}/{s}", .{ parent, executable }); + }; + if (std.c.access(path.ptr, 1) != 0) return error.V9fsHelperNotFound; + return path; +} + +fn usage() void { + std.debug.print( + \\usage: pardes-v9fs --launch SOCKET -- /absolute/shell [args...] + \\ Ask sudo in this terminal, mount privately, and start an unprivileged shell. + \\ PARDES_MOUNT names the mount. The caller's environment is preserved with sudo -E. + \\internal: pardes-v9fs SOCKET MOUNTPOINT UID GID -- /absolute/command [args...] + \\ Requires explicit root execution. Never install setuid or grant blanket NOPASSWD. + \\ + , .{}); +} + +var sudo_pid: std.atomic.Value(c_int) = .init(-1); + +fn forwardSignal(sig: std.posix.SIG) callconv(.c) void { + const pid = sudo_pid.load(.monotonic); + if (pid > 0) _ = kill(pid, @intCast(@intFromEnum(sig))); +} + +fn launch(arena: std.mem.Allocator, args: []const [:0]const u8) !u8 { + if (args.len < 5 or !std.mem.eql(u8, args[3], "--")) return error.InvalidArguments; + if (geteuid() == 0 or getuid() != geteuid()) return error.UnprivilegedLaunchRequired; + if (!std.fs.path.isAbsolute(args[2]) or !std.fs.path.isAbsolute(args[4])) return error.AbsolutePathRequired; + var own: [4096]u8 = undefined; + const helper = try selfPath(&own); + var target = "/tmp/pardes-v9fs-XXXXXX".*; + if (mkdtemp(&target) == null) return error.MountDirectoryFailed; + defer _ = rmdir(&target); + try check(setenv("PARDES_MOUNT", &target, 1), "export mount path"); + inline for (.{ "PATH", "HOME", "USER", "LOGNAME", "SHELL" }) |name| { + const saved = "PARDES_V9FS_" ++ name; + if (std.c.getenv(name)) |value| { + try check(setenv(saved, value, 1), "preserve shell environment"); + } else _ = unsetenv(saved); + } + const uid = try std.fmt.allocPrintSentinel(arena, "{d}", .{getuid()}, 0); + const gid = try std.fmt.allocPrintSentinel(arena, "{d}", .{std.c.getgid()}, 0); + const prefix = [_]?[*:0]const u8{ "sudo", "-E", "--", helper.ptr, args[2].ptr, &target, uid.ptr, gid.ptr, "--" }; + const argv = try arena.allocSentinel(?[*:0]const u8, prefix.len + args.len - 4, null); + @memcpy(argv[0..prefix.len], &prefix); + for (args[4..], prefix.len..) |arg, i| argv[i] = arg.ptr; + std.debug.print("Mounting Pardes at {s}\n", .{target}); + const pid = fork(); + if (pid < 0) return error.ForkFailed; + if (pid == 0) { + _ = execvp("sudo", argv.ptr); + _exit(127); + } + sudo_pid.store(pid, .monotonic); + const action: std.posix.Sigaction = .{ .handler = .{ .handler = forwardSignal }, .mask = std.posix.sigemptyset(), .flags = 0 }; + for ([_]std.posix.SIG{ .HUP, .INT, .TERM }) |sig| std.posix.sigaction(sig, &action, null); + var status: c_int = 0; + while (waitpid(pid, &status, 0) < 0) { + if (std.posix.errno(-1) != .INTR) return error.WaitFailed; + } + sudo_pid.store(-1, .monotonic); + return if (status & 0x7f == 0) @intCast((status >> 8) & 0xff) else @intCast(128 + (status & 0x7f)); +} + +fn check(rc: c_int, operation: []const u8) !void { + if (rc == 0) return; + const err = std.posix.errno(rc); + std.debug.print("v9fs: {s}: {s}\n", .{ operation, @tagName(err) }); + return error.SystemCallFailed; +} + +fn userId(text: []const u8) !c_uint { + const id = std.fmt.parseInt(c_uint, text, 10) catch return error.InvalidUserId; + if (id == 0 or id == std.math.maxInt(c_uint)) return error.InvalidUserId; + return id; +} + +pub fn main(init: std.process.Init) !void { + if (builtin.os.tag != .linux) return error.LinuxRequired; + const arena = init.arena.allocator(); + const args = try init.minimal.args.toSlice(arena); + if (args.len == 2 and std.mem.eql(u8, args[1], "--help")) { + usage(); + return; + } + if (args.len > 1 and std.mem.eql(u8, args[1], "--launch")) { + const status = try launch(arena, args); + std.process.exit(status); + } + if (args.len < 7 or !std.mem.eql(u8, args[5], "--")) { + usage(); + return error.InvalidArguments; + } + for ([_][]const u8{ args[1], args[2], args[6] }) |path| { + if (!std.fs.path.isAbsolute(path)) return error.AbsolutePathRequired; + } + const uid = try userId(args[3]); + const gid = try userId(args[4]); + if (getuid() != geteuid()) return error.SetuidInstallationUnsupported; + if (geteuid() != 0) { + std.debug.print("v9fs: native 9P mounts need CAP_SYS_ADMIN in the initial user namespace; use --launch to ask sudo in this terminal.\n", .{}); + return error.MountPrivilegeRequired; + } + + // Prepare everything before changing namespace or credentials. + const options = try std.fmt.allocPrintSentinel(arena, "trans=unix,version=9p2000,cache=none,access=any,uname={d},dfltuid={d},dfltgid={d}", .{ uid, uid, gid }, 0); + const argv = try arena.allocSentinel(?[*:0]const u8, args.len - 6, null); + for (args[6..], 0..) |arg, i| argv[i] = arg.ptr; + + try check(unshare(0x00020000), "create private mount namespace (CAP_SYS_ADMIN required)"); // CLONE_NEWNS + try check(mount(null, "/", null, (1 << 14) | (1 << 18), null), "make mount propagation recursively private"); // MS_REC | MS_PRIVATE + try check(mount(args[1].ptr, args[2].ptr, "9p", 2 | 4 | 8, options.ptr), "mount 9P2000 over Unix socket (kernel 9p and 9pnet_fd support required)"); // NOSUID | NODEV | NOEXEC + + try check(setgroups(0, null), "clear supplementary groups"); + const account = std.c.getpwuid(uid) orelse return error.UserAccountNotFound; + try check(initgroups(account.name orelse return error.UserAccountNotFound, gid), "restore user groups"); + try check(setresgid(gid, gid, gid), "drop group privileges"); + try check(setresuid(uid, uid, uid), "drop user privileges"); + // sudo can replace identity variables and PATH even with -E. Restore + // those values only after dropping privileges. + inline for (.{ "PATH", "HOME", "USER", "LOGNAME", "SHELL" }) |name| { + const saved = "PARDES_V9FS_" ++ name; + if (std.c.getenv(saved)) |value| { + try check(setenv(name, value, 1), "restore shell environment"); + _ = unsetenv(saved); + } + } + _ = execv(args[6].ptr, argv.ptr); + // Namespace destruction releases the mount when its last process exits. + try check(-1, "execute unprivileged command"); +} diff --git a/src/main.zig b/src/main.zig index e4aef61e..2d7b6538 100644 --- a/src/main.zig +++ b/src/main.zig @@ -56,7 +56,7 @@ const help_text = \\ --tty start as one shell pane already in tty mode \\ -n <count> initial shell panes: 1 (default) or 3 (classic). \\ A FILE argument boots just that file instead. - \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode + \\ --tty-toggle <key> use Ctrl-<key> to toggle tty/editor mode \\ -l <dump.zon> load a dump of another instance (see Dump) \\ --nested run a full session even inside another pardes. \\ Without it, a pardes started inside a pardes diff --git a/src/panes.zig b/src/panes.zig index 6a5a2739..d72e1611 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -89,6 +89,8 @@ pub const Pane = struct { rows: u16, greet: bool = false, pending_command: Terminal.PendingCommand = .{}, + /// Native host queues the Linux v9fs launcher in this pane's initial shell. + v9fs_on_spawn: bool = false, file: ?File.State = null, image: ?Image.State = null, pdf: PdfSlot = if (Pdf.enabled) null else {}, @@ -6290,8 +6292,46 @@ pub const Terminal = struct { } /// Encode one key for the program that owns a raw terminal and queue its pty - /// write. Global chords and mode routing have already been handled by core. + /// write, respecting the application's keyboard protocol and terminal modes. pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { + if (comptime enabled) { + const pane = p.panes[id] orelse return; + const state = pane.terminal orelse return; + const input = ghostty_vt.input; + const physical: input.Key = switch (key.cp) { + Key.enter => .enter, + Key.backspace => .backspace, + Key.tab => .tab, + Key.escape => .escape, + Key.up => .arrow_up, + Key.down => .arrow_down, + Key.left => .arrow_left, + Key.right => .arrow_right, + Key.home => .home, + Key.end => .end, + Key.page_up => .page_up, + Key.page_down => .page_down, + Key.delete => .delete, + else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, + }; + var utf8: [4]u8 = undefined; + const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) + utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] + else + ""; + var buffer: [256]u8 = undefined; + var writer = std.Io.Writer.fixed(&buffer); + input.encodeKey(&writer, .{ + .key = physical, + .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }, + .consumed_mods = .{ .shift = key.text.len > 0 and key.shift }, + .utf8 = text, + .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0, + }, input.KeyEncodeOptions.fromTerminal(&state.vt)) catch return; + if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } }); + return; + } var control: [1]u8 = undefined; const bytes: ?[]const u8 = blk: { if (key.ctrl) { diff --git a/src/pardes.zig b/src/pardes.zig index 35080163..5ab4dcb9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1361,6 +1361,25 @@ test "Tty spawns a raw shell in the caller's directory" { try std.testing.expect(found); } +test "Tty9p marks only the new Linux terminal for a mounted shell" { + if (comptime !hosted or @import("builtin").os.tag != .linux) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + p.fs.socket_path = "/tmp/pardes-test.sock"; + try std.testing.expect(p.executeBuiltinLine(0, "Tty9p")); + try std.testing.expect(p.active != 0); + try std.testing.expect(!p.panes[0].?.v9fs_on_spawn); + const pane = p.panes[p.active].?; + try std.testing.expect(pane.isTerminal() and pane.mode == .tty); + try std.testing.expect(pane.v9fs_on_spawn); + try std.testing.expect(!pane.greet); + try std.testing.expect(!p.takesCommandLine(p.active)); + panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); + try std.testing.expect(p.takesCommandLine(p.active)); + panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); + try std.testing.expect(!p.takesCommandLine(p.active)); +} + test "first document fallback splits like Tty from the originating pane" { for ([_]u16{ 100, 198 }) |width| for (0..3) |from| { for ([_]bool{ false, true }) |collapsed| { @@ -6200,6 +6219,10 @@ pub const Pardes = struct { fn takesCommandLine(p: *const Pardes, id: usize) bool { const pane = p.panes[id] orelse return false; if (!pane.isTerminal()) return false; + // A mounted shell sits behind sudo's process supervisor. Its OSC 133 + // prompt marks identify input readiness; the launcher's executable is + // not the shell executable used by the ordinary process heuristic. + if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); return !p.hostTtyTaken(id); } @@ -7696,6 +7719,15 @@ pub const Pardes = struct { fn handleKey(p: *Pardes, key: Key) void { if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; + if (pane.isTerminal() and pane.mode == .tty and !pane.tag_edit) { + p.leader_on = false; + p.ctrl_w_pending = false; + if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) + return p.toggleTty(p.active); + if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) + return p.runBuiltin(.Last, p.active, "", null); + return panes.Terminal.forwardKey(p, p.active, key); + } if (p.leader_on) return p.leaderKey(key); if (p.ctrl_w_pending) { p.ctrl_w_pending = false; @@ -7705,7 +7737,7 @@ pub const Pardes = struct { return; } // insert mode owns Ctrl-w (delete word backward, helix); the focus - // prefix keeps normal/tty + // prefix applies outside raw tty mode if (hit(key, config.window_prefix) and pane.mode != .insert) { p.ctrl_w_pending = true; return; @@ -7736,18 +7768,8 @@ pub const Pardes = struct { const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { - if (pane.mode == .tty and tty_alt) return p.runBuiltin(.Last, p.active, "", null); return p.toggleTty(p.active); } - // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. - if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) - return p.runBuiltin(.Last, p.active, "", null); - if (pane.isTerminal() and pane.mode == .tty) { - // Clipboard first: `hit` ignores a shift no binding asked for, so - // the plain Ctrl-V below would otherwise swallow Ctrl-Shift-V. - if (hit(key, config.tty_paste_clipboard)) return p.clipRequest(p.active, .after); - if (hit(key, config.tty_paste)) return p.typeToTty(p.active, pane, p.yank orelse return); - } if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { const submit = hit(key, config.search_submit); switch (pane.prompt) { @@ -11287,10 +11309,23 @@ pub const Pardes = struct { /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. pub fn spawnTty(p: *Pardes, from: usize) void { + p.spawnTtyWithMount(from, false); + } + + pub fn spawnV9fsTty(p: *Pardes, from: usize) void { + if (comptime !hosted or @import("builtin").os.tag != .linux) + return p.reportError(from, "Tty9p", error.LinuxRequired); + if (p.fs.socket_path.len == 0) + return p.reportError(from, "Tty9p", error.SessionSocketRequired); + p.spawnTtyWithMount(from, true); + } + + fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) void { const src = p.panes[from] orelse return; const free = p.freeSlot() orelse return; const nt = p.newShell(free, paneDir(src)) catch return; - nt.greet = true; + nt.greet = !v9fs; + nt.v9fs_on_spawn = v9fs; nt.mode = .tty; const parent = layout.splitParent(p, from); const f = layout.findPane(p, parent).?; @@ -13181,7 +13216,7 @@ test "Esc back into a tty leaves its view at the prompt" { const live = sp.terminal.?.vt.screens.active.pages.scrollbar().offset; try std.testing.expect(live > 0); - p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // out to the doc + p.update(.{ .key = .{ .cp = Key.escape } }); // shell-prompt heuristic: out to the doc try std.testing.expect(p.active != shell); p.update(.{ .key = .{ .cp = Key.escape } }); // ...and back in p.sync(); @@ -13234,33 +13269,42 @@ test "Esc back into a file leaves its view where it was" { try std.testing.expectEqual(@as(usize, @intCast(row)) -| @as(usize, dp.rows) / 2, dp.file.?.scroll); } -test "Shift-Esc in tty hops to the doc and leaves the shell in tty" { +test "raw tty Escape only leaves at a shell prompt" { if (platform == .web) return; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); defer p.deinit(); - p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); - p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + p.spawnTty(0); const shell = p.active; - const shell_pane = p.panes[shell].?; - try std.testing.expect(shell_pane.isTerminal()); - - // Shift-Esc still gets you IN, exactly as the configured Ctrl-key does. - const shift_esc: Key = .{ .cp = Key.escape, .shift = true }; - p.update(.{ .key = shift_esc }); - try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); - - p.update(.{ .key = shift_esc }); - try std.testing.expect(p.active != shell); - try std.testing.expect(!p.panes[p.active].?.isTerminal()); - try std.testing.expectEqual(Pane.Mode.tty, shell_pane.mode); + const pane = p.panes[shell].?; + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + for ([_]Key{ + .{ .cp = Key.escape, .shift = true }, + }) |key| { + p.update(.{ .key = key }); + try std.testing.expectEqual(shell, p.active); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + try std.testing.expect(drainWrites(p, &buf).len > 0); + } + p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); +} - // The configured Ctrl-key is now the only thing that leaves tty in place. - p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); // back to the shell - try std.testing.expectEqual(shell, p.active); - p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); - try std.testing.expectEqual(Pane.Mode.normal, shell_pane.mode); +test "raw tty Ctrl-B switches to editor mode without sending child input" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .file = "mise.toml" }); + defer p.deinit(); + p.spawnTty(0); + const shell = p.active; + p.sync(); + var buf: [256]u8 = undefined; + _ = drainWrites(p, &buf); + p.update(.{ .key = .{ .cp = 'b', .ctrl = true } }); try std.testing.expectEqual(shell, p.active); + try std.testing.expectEqual(Pane.Mode.normal, p.panes[shell].?.mode); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); } test "hopping between two panes does not grow the jump stack" { @@ -13387,41 +13431,45 @@ fn drainWrites(p: *Pardes, buf: []u8) []const u8 { return buf[0..n]; } -test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" { +test "raw tty forwards Ctrl letters and former global shortcuts to the child" { if (platform == .web) return; - const gpa = std.testing.allocator; - const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); - - const pane = p.panes[0].?; - panes.Terminal.enterTty(p, 0); - try std.testing.expectEqual(Pane.Mode.tty, pane.mode); - p.setYank("one\ntwo"); - - p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); - try std.testing.expectEqualStrings("one\rtwo", drainWrites(p, &buf)); - // and it asked the desktop for nothing on the way - try std.testing.expect(p.clip_pending == null); - - // Under mode 2004 the same keystroke brackets instead, which is what stops - // readline from RUNNING a multi-line paste. - pane.terminal.?.vt.modes.set(.bracketed_paste, true); - p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); - try std.testing.expectEqualStrings("\x1b[200~one\ntwo\x1b[201~", drainWrites(p, &buf)); - - // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and the - // answer lands at the program rather than in an edit buffer. - pane.terminal.?.vt.modes.set(.bracketed_paste, false); + p.setYank("must not be pasted"); + for ('a'..'z' + 1) |letter| { + if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut + p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } }); + const expected = [_]u8{@intCast(letter - 'a' + 1)}; + // The terminal encoder distinguishes Ctrl-I/M from Tab/Return. + const encoded: []const u8 = switch (letter) { + 'i' => "\x1b[105;5u", + 'm' => "\x1b[109;5u", + else => &expected, + }; + try std.testing.expectEqualStrings(encoded, drainWrites(p, &buf)); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode); + try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null); + } p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); - try std.testing.expect(p.clip_pending != null); - try std.testing.expectEqualStrings("", drainWrites(p, &buf)); - p.update(.{ .paste = "from-desktop" }); - try std.testing.expectEqualStrings("from-desktop", drainWrites(p, &buf)); - // the clipboard did not clobber the register on its way through - try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); + try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf)); + try std.testing.expect(p.clip_pending == null); + for ([_]u8{ 'n', 'c' }) |letter| { + p.update(.{ .key = .{ .cp = letter, .alt = true } }); + const expected = [_]u8{ 0x1b, letter }; + try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf)); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expect(p.panes[1] == null); + } + p.update(.{ .key = .{ .cp = Key.up } }); + try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf)); + p.panes[0].?.terminal.?.vt.modes.set(.cursor_keys, true); + p.update(.{ .key = .{ .cp = Key.up } }); + try std.testing.expectEqualStrings("\x1bOA", drainWrites(p, &buf)); + p.update(.{ .key = .{ .cp = Key.left, .ctrl = true } }); + try std.testing.expectEqualStrings("\x1b[1;5D", drainWrites(p, &buf)); } test "an unasked desktop paste reaches a tty pane's program, not its buffer" { diff --git a/src/tutor.txt b/src/tutor.txt index 7ee56add..ae22ede9 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -26,7 +26,7 @@ SIX PARTS, ordered by what is most different from editors you know: 1 — THE MOUSE. Acme's three buttons; nothing like vim. 2 — PANES. Moving between them. Esc, Shift-Esc, Ctrl-w. - 3 — THE TTY. A terminal is a pane. Ctrl-b and Shift-Esc. + 3 — THE TTY. Raw input, prompt-aware Esc, and the mode tag. 4 — DETACHED. The core outliving the terminal showing it. 5 — THE KEYS. Helix-style modal, and where it differs. 6 — THE REST. PDFs, images, the language backend, scripting. @@ -149,42 +149,26 @@ INSERT (^) the same page; keys type an insertion overlay. TTY ($) the REAL shell. Prompts back, keys straight to the pty. - THE TOGGLE - Ctrl-b in and out of raw TTY, on a terminal pane - Shift-Esc the same toggle, spelled for hosts that report modifiers - on Escape + ENTERING TTY + Ctrl-b toggle raw TTY/editor mode on a terminal pane + Shift-Esc also enters raw TTY from editor mode + Togglettymode in the pane tag switches modes in either direction - `pardes --tty-toggle=g` makes it Ctrl-g instead — any letter but `c`, - which stays SIGINT. + `pardes --tty-toggle=g` chooses Ctrl-g for toggling instead. + Entering TTY moves the shell's real cursor to the place you selected + on its prompt input line, using its OSC 133 prompt marks. - Entering TTY is tty-NATIVE, and this is the part worth understanding. - If the shell is at a prompt and your modal cursor sits on the input - line, pardes first moves the shell's REAL cursor to that spot, counting - back the prompt columns that were hidden from you. It does that with - arrow keys the shell already understands, through the shell's own OSC - 133 semantic prompt marks — it does not fake a cursor on top. So you - navigate the clean page, hit the toggle where you want to keep typing, - and you are IN the live shell at that spot. + RAW INPUT + Ctrl-b switches to editor mode. Other keys go to the child, + including Ctrl-o, Ctrl-w, Ctrl-V, Ctrl-Shift-V, Alt shortcuts, and + modified Escape. Plain Esc at a detected shell prompt hops + to the previous pane. While a program owns the terminal, Esc goes + to that program too. Use the Togglettymode tag to leave raw input + in place. Desktop paste events still feed the child. - LEAVING - Ctrl-b back to normal on this pane - Shift-Esc hop AWAY, leaving this pane in TTY — so coming back - lands you in the program you left - Esc goes to the program... unless the leaf process is a - SHELL sitting at its prompt, in which case it hops away - exactly like Shift-Esc - - That last rule is the one people ask about. A shell prompt is a pane - you can leave, so plain Esc leaves it; a full-screen program (vim, a - pager, an agent) has taken the tty, so Esc belongs to the program and - you need Shift-Esc. Pardes knows which by asking whether the pane's - leaf process is still the prompt it forked. - - PASTING INTO THE PROGRAM - Ctrl-V type the DEFAULT register — what `y` put there - Ctrl-Shift-V ask the SYSTEM clipboard - SPC p and the mouse chords cannot reach here: the pty owns every - keystroke and every button. + On Linux, Tty9p (SPC n 9 from editor mode) opens a terminal with + this session mounted through kernel v9fs. It asks sudo in that pane, + then starts your normal shell. $PARDES_MOUNT names its mounted tree. `Filter` in a terminal's tag toggles a pane-local, theme-keyed palette. @@ -456,11 +440,12 @@ typed Alt-n new terminal below Alt-c pane into a new column TTY a terminal IS a pane - Ctrl-b or Shift-Esc toggles the shell, landing its cursor - where you navigated + Ctrl-b toggles raw input; Shift-Esc enters from editor mode + Togglettymode in the tag switches modes in either direction Esc goes to the program — except at a shell PROMPT, where - it hops away like Shift-Esc - Ctrl-V default register, Ctrl-Shift-V system clipboard + it hops to the previous pane + Ctrl-b switches to editor mode + All other keys belong to the child, including Ctrl-V DETACHED pardes --detach[=name] the core, no terminal pardes --attach[=name] a frontend for it |
