summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p.zig18
-rw-r--r--src/builtins.zig8
-rw-r--r--src/config.zig8
-rw-r--r--src/fs-help.txt65
-rw-r--r--src/fs.zig64
-rw-r--r--src/host_io.zig16
-rw-r--r--src/linux/v9fs.zig201
-rw-r--r--src/main.zig2
-rw-r--r--src/panes.zig42
-rw-r--r--src/pardes.zig178
-rw-r--r--src/tutor.txt61
11 files changed, 535 insertions, 128 deletions
diff --git a/src/9p.zig b/src/9p.zig
index 9513fdb5..2948b9ed 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -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.
diff --git a/src/fs.zig b/src/fs.zig
index 63d2ed3a..5ec09d3e 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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