summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig1103
1 files changed, 873 insertions, 230 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 968b73fb..405cc3f2 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -46,6 +46,13 @@ pub const allocators = @import("allocators.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
+/// The host seam: one struct of optional function pointers, with in-core
+/// defaults for every method a host leaves null. See src/host.zig.
+const host_mod = @import("host.zig");
+pub const Host = host_mod.Host;
+pub const Fanout = host_mod.Fanout;
+pub const Fallback = host_mod.Fallback;
+pub const fallback_dump_path = host_mod.fallback_dump_path;
/// Tracy's frame boundary, re-exported so a host that is not a shell — the
/// fling benchmark — can delimit the same frames the tty loop delimits without
/// reaching around the core for src/tracy.zig and its build options. A no-op
@@ -55,6 +62,12 @@ pub const frameMark = tracy.frameMark;
pub const Platform = enum { tty, gui, web, macos };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
+/// A build with no host but its display: the embedded source filesystem, the
+/// in-process clipboard, silent ptys. Comptime, and its own option module
+/// rather than a `pardes_config` field, because it is the one setting that
+/// produces a SECOND executable from the same graph — see `run-isolated`.
+pub const isolated = @import("pardes_isolation").isolated;
+
/// Frontends that draw their own text, and can therefore be told which face to
/// wear. On the tty the font belongs to the terminal emulator and in the
/// browser it belongs to the page, so there the Font builtins are not
@@ -147,6 +160,11 @@ const dirty_marker = " *";
// parser recognizes them as defaults while new dumps carry an explicit tail.
const legacy_pane_tail = " Del";
const legacy_file_pane_tail = " Save Del";
+// The defaults from the release before Newtty joined every tagline. Recognized
+// so a dump written then upgrades instead of keeping the old word as a tail.
+const prev_pane_tail = " New Del";
+const prev_file_pane_tail = " Save New Del";
+const prev_terminal_pane_tail = " New Del Filter";
// Builtins: executing the name (middle-click / Tab) runs it through the ONE
// dispatcher (runBuiltin, reached from execute), no matter where the name
@@ -570,7 +588,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
p.acknowledgeShell(0, "/bin/sh", false);
const pane = p.panes[0].?;
- try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(pane));
+ try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(pane));
const tag_x = p.rects[0].x + config.GUTTER;
const tag_y = p.rects[0].y;
@@ -580,19 +598,22 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
// the rendered tag rather than assumed to be at its left edge.
const rendered = try p.tagText(p.scratch.allocator(), pane);
const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1;
+ var panes_before: usize = 0;
+ for (p.panes) |s| if (s != null) {
+ panes_before += 1;
+ };
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = new_x, .row = tag_y } });
p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = new_x, .row = tag_y } });
- var saw_new = false;
while (p.nextEffect()) |effect| switch (effect) {
- .new_file => |request| {
- try std.testing.expectEqual(@as(u8, 0), request.pane);
- try std.testing.expectEqual(pane.serial, request.serial);
- saw_new = true;
- },
.write => return error.NewFellThroughToPty,
else => {},
};
- try std.testing.expect(saw_new);
+ var panes_after: usize = 0;
+ for (p.panes) |s| if (s != null) {
+ panes_after += 1;
+ };
+ try std.testing.expectEqual(panes_before + 1, panes_after);
+ try std.testing.expect(p.panes[p.active].?.file.?.output != null); // the scratch
// Argument-taking builtins use the same gesture path: a tag sweep is the
// complete command line, and Theme consumes its tail without a PTY write.
@@ -629,7 +650,6 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
@memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
sent_len += w.bytes.slice().len;
},
- .new_file => return error.UnknownCommandRanNew,
else => {},
};
try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]);
@@ -684,7 +704,7 @@ test "an untouched tagline ends where its layout column's widest one does" {
const f = p.layoutFindTerm(p.active).?;
p.layoutInsert(f.col, f.idx + 1, below_id);
p.splitBelow(p.active, below);
- p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/off/the/end/of/its/own/tagline");
+ p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/the/end/of/its/own/tagline");
p.sync();
while (p.nextEffect()) |_| {}
@@ -696,7 +716,7 @@ test "an untouched tagline ends where its layout column's widest one does" {
// path, which is the whole point (equal at tw - tag_right_pad would prove
// nothing: that is where both sat before)
try std.testing.expectEqual(below_tag.len, above_tag.len);
- try std.testing.expectEqualStrings(" New Del Filter", above_tag[above_tag.len - " New Del Filter".len ..]);
+ try std.testing.expectEqualStrings(" New Newtty Del Filter", above_tag[above_tag.len - " New Newtty Del Filter".len ..]);
try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad);
try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER);
@@ -745,7 +765,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
});
p.restoreTail(terminal, terminal_old);
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
// The immediately preceding release used the generic current default on
// terminals. It upgrades too, including any saved layout padding.
@@ -754,7 +774,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
});
p.restoreTail(terminal, terminal_previous);
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
try p.tagPrefix(terminal),
@@ -786,7 +806,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
.terminal = .{ .cwd = "/historical/cwd" },
});
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
// This happens to be the historical FILE default, but on a terminal it is
// user-owned text and must not be swallowed by migration.
@@ -808,7 +828,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
});
p.restoreTail(file, file_old);
try std.testing.expect(!file.tag_init);
- try std.testing.expectEqualStrings(" Save New Del", Pardes.curTail(file));
+ try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(file));
const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{
try p.tagPrefix(file),
@@ -832,7 +852,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
// with only `img PATH`. Their custom tails still migrate through the
// pane-specific legacy-prefix recognizer.
const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{});
- try std.testing.expectEqualStrings(" New Del", Pardes.curTail(image_doc));
+ try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc));
p.restoreDumpTail(image_doc, .{
.kind = .image,
.tag = "img /tmp/legacy image.ppm Keep Del",
@@ -843,6 +863,54 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice());
}
+test "Joincol folds the active column into its right neighbor, keeping its panes" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const right = p.freeSlot().?;
+ _ = try p.newShell(right, "");
+ try std.testing.expect(p.layoutSplitColumn(0, right, false));
+ while (p.nextEffect()) |_| {}
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+
+ p.active = 0; // the left column is current
+ p.joinCol();
+ try std.testing.expectEqual(@as(usize, 1), p.ncol);
+ const lf = p.layoutFindTerm(0) orelse return error.LostLeftPane;
+ const rf = p.layoutFindTerm(right) orelse return error.LostRightPane;
+ try std.testing.expectEqual(lf.col, rf.col);
+
+ p.joinCol(); // no right neighbor left: inert
+ try std.testing.expectEqual(@as(usize, 1), p.ncol);
+}
+
+test "Newtty spawns a raw shell in the caller's directory" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.setCwd(0, "/tmp/newtty-dir");
+
+ p.spawnTty(0);
+ const free = p.active;
+ try std.testing.expect(free != 0);
+ const nt = p.panes[free] orelse return error.NoNewPane;
+ try std.testing.expect(nt.isTerminal());
+ try std.testing.expect(nt.mode == .tty);
+
+ var found = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .spawn => |s| if (@as(usize, s.pane) == free) {
+ try std.testing.expectEqualStrings("/tmp/newtty-dir", s.cwd.slice());
+ found = true;
+ },
+ else => {},
+ };
+ try std.testing.expect(found);
+}
+
test "an unsaved file marker sits between its path and builtins until Save" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
@@ -909,11 +977,13 @@ const FakeTtyQuery = struct {
taken: [MAX_PANES]bool = @splat(false),
asked: usize = 0,
+ const vtable: Host.VTable = .{ .pull_tty_taken = answer };
+
fn install(f: *FakeTtyQuery, p: *Pardes) void {
- p.tty_query = .{ .ctx = f, .taken = &answer };
+ p.host = .{ .ctx = f, .vtable = &vtable };
}
- fn answer(ctx: ?*anyopaque, pane: usize) bool {
+ fn answer(ctx: ?*anyopaque, pane: u8) bool {
const f: *FakeTtyQuery = @ptrCast(@alignCast(ctx.?));
f.asked += 1;
return f.taken[pane];
@@ -1186,145 +1256,201 @@ test "the host is asked about a tty only where a command line is about to go" {
try std.testing.expectEqual(@as(usize, 2), host.asked);
}
-test "New completes as an empty watched file in the calling column and focuses it" {
+test "New opens an empty scratch below the caller, inheriting its directory" {
const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
defer p.deinit();
- while (p.nextEffect()) |_| {} // initial shell spawns
+ while (p.nextEffect()) |_| {}
const source: usize = 2; // the right column; active starts in the left
+ p.setCwd(source, "/tmp/pardes-scratch-dir");
const source_col = p.layoutFindTerm(source).?.col;
- try std.testing.expect(p.executeBuiltinLine(source, "New"));
- var request: ?Effect = null;
- while (p.nextEffect()) |effect| switch (effect) {
- .new_file => request = effect,
- else => {},
- };
- const new_effect = request orelse return error.MissingNewFileEffect;
- const req = new_effect.new_file;
- try std.testing.expectEqual(@as(u8, @intCast(source)), req.pane);
- try std.testing.expectEqual(p.panes[source].?.serial, req.serial);
- // Focus changes while the shell is doing IO. Placement must still use the
- // effect's calling-pane token, never this later-active left column.
- p.active = 0;
- try std.testing.expect(p.openNewFile(req.pane, req.serial, "/tmp/pardes-test-new-a"));
+ try std.testing.expect(p.executeBuiltinLine(source, "New"));
+ // no shell IO: the scratch is created in-core, focused, below the caller
+ while (p.nextEffect()) |_| {}
const id = p.active;
- try std.testing.expect(id != 0 and id != source);
+ try std.testing.expect(id != source);
try std.testing.expectEqual(source_col, p.layoutFindTerm(id).?.col);
- const sf = p.layoutFindTerm(source).?;
- try std.testing.expectEqual(id, p.col_terms[sf.col][sf.idx + 1]);
+ const np = p.panes[id].?;
+ try std.testing.expect(np.file.?.output != null); // an output buffer, empty
+ try std.testing.expectEqual(@as(usize, 0), np.file.?.content.len);
+ // its directory is a LIVE link to the opener, not a snapshot taken at New
+ try std.testing.expectEqualStrings("/tmp/pardes-scratch-dir", Pardes.paneDir(np));
+ p.setCwd(source, "/tmp/pardes-moved");
+ try std.testing.expectEqualStrings("/tmp/pardes-moved", Pardes.paneDir(np));
+}
- const file = p.panes[id].?.file orelse return error.NewIsNotAFile;
- try std.testing.expectEqualStrings("/tmp/pardes-test-new-a", file.path);
- try std.testing.expectEqual(@as(usize, 0), file.content.len);
- try std.testing.expect(file.output == null);
- try std.testing.expect(!p.panes[id].?.isTerminal());
+test "Save on a scratch asks for a path in its inherited dir and makes it a file" {
+ const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
- var watched = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .watch => |w| if (w.pane == id and w.on) {
- watched = true;
- },
- else => {},
- };
- try std.testing.expect(watched);
+ const source: usize = 2;
+ p.setCwd(source, "/tmp/pardes-save-dir");
+ try std.testing.expect(p.executeBuiltinLine(source, "New"));
+ const id = p.active;
+ const np = p.panes[id].?;
+ p.sync(); // the frame boundary that gives the new pane its geometry
+ while (p.nextEffect()) |_| {}
- // It is an ordinary file from here on: Save uses the normal file effect,
- // and the dump contains its path and empty content.
+ // Save on a scratch arms a PATH input, prefilled with the inherited dir
try std.testing.expect(p.executeBuiltinLine(id, "Save"));
+ try std.testing.expect(np.hasSavePrompt());
+ try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/"));
+
+ // typing the filename and submitting converts it into an ordinary file
+ try std.testing.expect(np.appendTag("note.txt"));
+ p.submitSave(id);
+ try std.testing.expect(np.file.?.output == null);
+ try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
var saved = false;
while (p.nextEffect()) |effect| switch (effect) {
- .save_file => |s| if (s.pane == id) {
+ .save_file => |sf| if (@as(usize, sf.pane) == id) {
saved = true;
},
else => {},
};
try std.testing.expect(saved);
- try p.dumpState();
- const state = try dump.readZon(std.testing.allocator, p.dump_out.?, "New-test");
- defer dump.free(std.testing.allocator, state);
- var dumped = false;
- for (state.panes) |dp| if (dp.file) |df| {
- if (std.mem.eql(u8, df.path, "/tmp/pardes-test-new-a")) {
- try std.testing.expectEqual(@as(usize, 0), df.content.len);
- dumped = true;
- }
- };
- try std.testing.expect(dumped);
- while (p.nextEffect()) |_| {} // write_dump
+}
+
+test "Save on a terminal writes its plaintext scrollback and stays a terminal" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ p.setCwd(0, "/tmp/pardes-tty-save");
+ p.update(.{ .output = .{ .pane = 0, .bytes = "hello scrollback\r\n" } });
+ while (p.nextEffect()) |_| {}
- // Del closes and unwatches, but there is intentionally no unlink effect:
- // adopted temporary documents preserve user data and dump restorability.
- try std.testing.expect(p.executeBuiltinLine(id, "Del"));
- try std.testing.expect(p.panes[id] == null);
- var unwatched = false;
+ try std.testing.expect(p.executeBuiltinLine(0, "Save"));
+ try std.testing.expect(pane.hasSavePrompt());
+ try std.testing.expect(pane.appendTag("log.txt"));
+ p.submitSave(0);
+
+ // the pane is untouched: still a terminal, no file behind it
+ try std.testing.expect(pane.isTerminal());
+ try std.testing.expect(pane.file == null);
+ const st = p.save_text orelse return error.MissingSaveText;
+ try std.testing.expectEqualStrings("/tmp/pardes-tty-save/log.txt", st.path);
+ try std.testing.expect(std.mem.indexOf(u8, st.content, "hello scrollback") != null);
+ var asked = false;
while (p.nextEffect()) |effect| switch (effect) {
- .watch => |w| if (w.pane == id and !w.on) {
- unwatched = true;
- },
+ .save_text => asked = true,
else => {},
};
- try std.testing.expect(unwatched);
+ try std.testing.expect(asked);
}
-test "New requests repeat, stay anchored, and reject stale or full completions" {
- const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+test "a host with no methods at all is a complete in-process pardes" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 });
defer p.deinit();
while (p.nextEffect()) |_| {}
- const source: usize = 2;
- const source_col = p.layoutFindTerm(source).?.col;
- try std.testing.expect(p.executeBuiltinLine(source, "New"));
- try std.testing.expect(p.executeBuiltinLine(source, "New"));
- var requests: [2]Effect = undefined;
- var n: usize = 0;
- while (p.nextEffect()) |effect| switch (effect) {
- .new_file => if (n < requests.len) {
- requests[n] = effect;
- n += 1;
- },
- else => {},
+ // A pump against the empty host renders a frame and blocks on nothing.
+ try p.pump(.{});
+ try std.testing.expect(!p.quit);
+
+ // the absent child is SILENT: the bytes are dropped, and nothing appears
+ // on the pane's screen to suggest a program answered
+ const before = p.panes[0].?.vt.screens.active.cursor.y;
+ p.perform(.{ .write = .{ .pane = 0, .bytes = .from("ls\r") } });
+ try std.testing.expectEqual(before, p.panes[0].?.vt.screens.active.cursor.y);
+
+ // the clipboard round-trips through the in-process one
+ p.yank = try gpa.dupe(u8, "copied");
+ p.perform(.set_clipboard);
+ try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items);
+
+ // ...and a save with no filesystem lands in the virtual one
+ const doc = try p.hxOpenFileContent("body\n");
+ var doc_id: u8 = 0;
+ for (p.panes, 0..) |slot, i| if (slot == doc) {
+ doc_id = @intCast(i);
};
- try std.testing.expectEqual(requests.len, n);
+ p.perform(.{ .save_file = .{ .pane = doc_id } });
+ try std.testing.expectEqualStrings("body\n", p.fallback.files.get(doc.file.?.path).?);
- p.active = 0;
- try std.testing.expect(p.openNewFile(requests[0].new_file.pane, requests[0].new_file.serial, "/tmp/pardes-test-new-1"));
- const first = p.active;
- p.active = 1; // change focus again before the second completion
- try std.testing.expect(p.openNewFile(requests[1].new_file.pane, requests[1].new_file.serial, "/tmp/pardes-test-new-2"));
- const second = p.active;
- try std.testing.expect(first != second);
- try std.testing.expectEqual(source_col, p.layoutFindTerm(first).?.col);
- try std.testing.expectEqual(source_col, p.layoutFindTerm(second).?.col);
- try std.testing.expect(!std.mem.eql(u8, p.panes[first].?.file.?.path, p.panes[second].?.file.?.path));
- while (p.nextEffect()) |_| {}
+ // a language backend nobody supplied answers nothing rather than hanging
+ p.perform(.{ .lsp = .{ .id = 7, .kind = .hover, .pane = doc_id, .offset = 0, .arg = .{} } });
+
+ p.perform(.quit);
+ try std.testing.expect(p.quit);
+}
+
+const RecordHost = struct {
+ gpa: std.mem.Allocator,
+ writes: std.ArrayListUnmanaged(u8) = .empty,
+ /// What this host would answer a clipboard read with, and whether it was
+ /// ever asked — a pull must reach exactly one host.
+ clipboard: []const u8 = "",
+ asked: usize = 0,
+ core: ?*Pardes = null,
- // A reused pane slot cannot answer an old request, and a malformed shell
- // answer cannot manufacture a nameless file.
- const old_serial = p.panes[source].?.serial;
- p.panes[source].?.serial +%= 1;
- try std.testing.expect(!p.openNewFile(@intCast(source), old_serial, "/tmp/pardes-test-stale"));
- try std.testing.expect(!p.openNewFile(@intCast(source), p.panes[source].?.serial, ""));
+ const vt: Host.VTable = .{ .push_pty_write = ptyWrite, .pull_read_clipboard = readClipboard };
- // Exhaust every remaining slot after a request was issued: completion
- // rejects it, which tells the shell to unlink the just-created candidate.
- const live_serial = p.panes[source].?.serial;
- for (0..MAX_PANES) |id| {
- if (p.panes[id] == null) _ = try p.newDocPane(id);
+ fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
+ _ = pane;
+ const self: *RecordHost = @ptrCast(@alignCast(ctx.?));
+ self.writes.appendSlice(self.gpa, bytes) catch {};
}
- try std.testing.expect(!p.openNewFile(@intCast(source), live_serial, "/tmp/pardes-test-full"));
- try std.testing.expect(p.executeBuiltinLine(source, "New"));
- var emitted_when_full = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .new_file => emitted_when_full = true,
- else => {},
+
+ fn readClipboard(ctx: ?*anyopaque) void {
+ const self: *RecordHost = @ptrCast(@alignCast(ctx.?));
+ self.asked += 1;
+ self.core.?.update(.{ .paste = self.clipboard });
+ }
+
+ fn host(self: *RecordHost) Host {
+ return .{ .ctx = self, .vtable = &vt };
+ }
+};
+
+test "a fan-out host reaches every wrapped host, each with its own state" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 10 });
+ defer p.deinit();
+
+ var a: RecordHost = .{ .gpa = gpa };
+ defer a.writes.deinit(gpa);
+ var b: RecordHost = .{ .gpa = gpa };
+ defer b.writes.deinit(gpa);
+
+ const wrapped = [_]Host{ a.host(), b.host() };
+ var fan: Fanout = .init(&wrapped);
+ p.host = fan.host();
+ p.perform(.{ .write = .{ .pane = 0, .bytes = .from("echo hi\r") } });
+
+ try std.testing.expectEqualStrings("echo hi\r", a.writes.items);
+ try std.testing.expectEqualStrings("echo hi\r", b.writes.items);
+
+ // A PULL reaches ONE host, and the name is what says so. Fanned out, both
+ // would answer and the core would paste the clipboard twice for one Ctrl-V.
+ a.core = p;
+ b.core = p;
+ a.clipboard = "from-a";
+ b.clipboard = "from-b";
+ const doc = p.panes[p.active].?;
+ doc.mode = .normal;
+ p.perform(.read_clipboard);
+ try std.testing.expectEqual(@as(usize, 1), a.asked);
+ try std.testing.expectEqual(@as(usize, 0), b.asked);
+
+ // a method NO wrapped host implements still falls back per-method
+ p.perform(.{ .open_link = .from("https://example.invalid") });
+ try std.testing.expectEqualStrings("https://example.invalid", p.fallback.link.items);
+}
+
+test "a builtin that needs a pane reports capacity failure when every slot is full" {
+ const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ for (0..MAX_PANES) |id| if (p.panes[id] == null) {
+ _ = try p.newDocPane(id);
};
- try std.testing.expect(!emitted_when_full);
- // Builtins that need another pane surface capacity/allocation failures on
- // the asking pane instead of silently doing nothing.
const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin;
- p.runBuiltin(help, source, "", null);
- try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null);
+ p.runBuiltin(help, 0, "", null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: NoPaneSlots") != null);
}
test "Msg writes the transient row by hand, bare or with text, and input ends it" {
@@ -1701,10 +1827,12 @@ pub const builtin_rows: [std.enums.values(Builtin).len]Row = blk: {
// not a derivation — see it there for why each word is in or out. This is the
// check that a rename cannot silently rot it.
comptime {
- @setEvalBranchQuota(20000); // the tokenizer walks the string one branch per byte, stringToEnum builds a map per name, and both sit behind the decl walk that folds Builtin
+ @setEvalBranchQuota(20000); // one branch per string byte, behind the decl walk that folds Builtin
var it = std.mem.tokenizeScalar(u8, config.topbar_str, ' ');
- while (it.next()) |w| {
- if (std.meta.stringToEnum(Builtin, w) == null) @compileError("topbar word is not a builtin: " ++ w);
+ words: while (it.next()) |w| {
+ for (@typeInfo(Builtin).@"enum".fields) |f|
+ if (std.mem.eql(u8, f.name, w)) continue :words;
+ @compileError("topbar word is not a builtin: " ++ w);
}
}
@@ -2943,22 +3071,6 @@ pub const Event = union(enum) {
tick,
};
-/// The one thing the core PULLS from the host instead of being pushed or
-/// emitting an effect: is a pane's terminal still the prompt the host forked,
-/// or has a program (vim, a pager, an agent) taken its tty?
-///
-/// An effect cannot answer it — `execute` has to choose a destination inside
-/// the update that asked, and an effect is drained after. A pushed fact could,
-/// and did, but only by having every host probe every pane's processes on every
-/// frame to answer a question that is asked when a human middle-clicks a word.
-/// So the host leaves a way to be asked, and the core asks where it decides
-/// (see `Pardes.takesCommandLine`). `ctx` is the host's own pty table; the
-/// answer must not re-enter the core.
-pub const TtyQuery = struct {
- ctx: ?*anyopaque,
- taken: *const fn (ctx: ?*anyopaque, pane: usize) bool,
-};
-
/// IO the core wants done. Payloads are inline (fixed buffers): effects are
/// queued values with no lifetime ties back into the core.
pub const Effect = union(enum) {
@@ -2969,11 +3081,10 @@ pub const Effect = union(enum) {
/// write this pane's file content to its path; the shell reads both off
/// the core (content is unbounded, effects are fixed-size values)
save_file: struct { pane: u8 },
- /// Create an empty, uniquely-named temporary file. `pane` + `serial`
- /// identify the pane/column that issued New even if focus moves before the
- /// shell completes the IO. The shell calls openNewFile with the path and
- /// unlinks it itself when that completion rejects the request.
- new_file: struct { pane: u8, serial: u32 },
+ /// write plaintext to a path WITHOUT converting the pane (a terminal's
+ /// scrollback save): the bytes and path are stashed in core.save_text,
+ /// read off the core exactly like save_file reads a file pane.
+ save_text: struct { pane: u8 },
/// a serialized state dump is ready in core.dump_out; write it to the
/// path dump.outPath resolves (acme-style: another instance loads it
/// with -l, or the Restore builtin loads it into this one)
@@ -3155,9 +3266,14 @@ const Prompt = union(enum) {
none,
search: u16,
pipe: u16,
+ /// Save on a scratch buffer or a terminal: the tail is a path to write to.
+ save: u16,
};
pub const Pane = struct {
+ /// A pane's working directory. `.inherited` is a live `*Pane` link kept
+ /// valid by deferred teardown (see PaneAllocator) + reapPanes' fixup.
+ pub const Cwd = union(enum) { none, inherited: *Pane, owned: []const u8 };
vt: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
/// The same allocator Pardes holds. A pane already owns heap (its content,
@@ -3263,10 +3379,11 @@ pub const Pane = struct {
ed_undo_len: usize = 0,
ed_redo: [term_pane.history_max]term_pane.Snapshot = undefined,
ed_redo_len: usize = 0,
- /// live working directory, shell-reported (setCwd); shown in the tag and
- /// the base for look-resolution of relative paths.
- cwd: [1024]u8 = undefined,
- cwd_len: u16 = 0,
+ /// Working directory: shell-reported bytes (.owned, in cwd_buf), a live
+ /// link to the pane it was opened from (.inherited), or unknown (.none).
+ /// The inherited pointer is kept valid by deferred pane teardown + fixup.
+ cwd: Cwd = .none,
+ cwd_buf: [1024]u8 = undefined,
/// modal cursor, at ABSOLUTE body rows of the pane's SURFACE (file lines,
/// or the terminal's shell rows with its edit buffer standing in). Tracks
/// the shell cursor until pinned by a click or a key.
@@ -3334,7 +3451,7 @@ pub const Pane = struct {
fn promptAt(p: *const Pane) ?u16 {
return switch (p.prompt) {
.none => null,
- .search, .pipe => |at| at,
+ .search, .pipe, .save => |at| at,
};
}
@@ -3352,6 +3469,13 @@ pub const Pane = struct {
};
}
+ fn hasSavePrompt(p: *const Pane) bool {
+ return switch (p.prompt) {
+ .save => true,
+ else => false,
+ };
+ }
+
fn appendTag(p: *Pane, text: []const u8) bool {
if (text.len > p.tag_tail.len - p.tag_tail_len) return false;
@memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text);
@@ -3374,7 +3498,18 @@ pub const Pane = struct {
}
pub fn cwdSlice(p: *const Pane) []const u8 {
- return p.cwd[0..p.cwd_len];
+ return switch (p.cwd) {
+ .none => "",
+ .owned => |dir| dir,
+ .inherited => |src| src.cwdSlice(),
+ };
+ }
+
+ /// Shell-reported directory: own the bytes in cwd_buf.
+ pub fn setOwnedCwd(pane: *Pane, dir: []const u8) void {
+ const n = @min(dir.len, pane.cwd_buf.len);
+ @memcpy(pane.cwd_buf[0..n], dir[0..n]);
+ pane.cwd = .{ .owned = pane.cwd_buf[0..n] };
}
pub fn isTerminal(pane: *const Pane) bool {
@@ -3382,6 +3517,18 @@ pub const Pane = struct {
return pane.file == null and pane.image == null and no_pdf;
}
+ /// The one coloring choice keyed on what a pane IS, so the highlight
+ /// producer (refreshHighlights) and the render pass agree on the algorithm.
+ pub const ColorAlgo = enum { none, tty, source, diff };
+ pub fn colorAlgo(pane: *const Pane) ColorAlgo {
+ if (pane.isTerminal()) return .tty;
+ if (pane.file) |f| {
+ if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff;
+ return .source;
+ }
+ return .none;
+ }
+
pub fn pdfPath(pane: *const Pane) ?[]const u8 {
if (comptime pdf_enabled) if (pane.pdf) |pv| return pv.path;
return null;
@@ -4689,6 +4836,9 @@ pub const Options = struct {
image_allocator: ?std.mem.Allocator = null,
pdf_allocator: ?std.mem.Allocator = null,
tree_sitter_allocator: ?std.mem.Allocator = null,
+ /// Where each frame's Surface text is built. Hosts pass a purpose-built
+ /// stack-fallback arena; null means the general allocator.
+ frame_allocator: ?std.mem.Allocator = null,
/// Initial grid. Shell contract: for LIVE sessions leave these at the
/// defaults and deliver the real size as the first resize EVENT — the core
/// defers an integrated shell's greeting until after a resize AND its OSC
@@ -4742,6 +4892,32 @@ pub const Haptic = enum { none, exec, look };
/// the field, so no other shell carries it.
const HapticSlot = if (platform == .macos) Haptic else void;
+/// Deferred pane teardown. A dropped pane's memory outlives the frame it died
+/// in: it is doomed here and actually torn down one full frame later, so any
+/// `*Pane` captured that frame — an effect, another pane's inherited cwd —
+/// stays valid long enough for the per-frame fixup pass to repair it. `fresh`
+/// holds this frame's drops, `stale` the previous frame's, freed next reap.
+pub const PaneAllocator = struct {
+ fresh: [2 * MAX_PANES]?*Pane = @splat(null),
+ stale: [2 * MAX_PANES]?*Pane = @splat(null),
+
+ fn doom(a: *PaneAllocator, pane: *Pane) void {
+ for (&a.fresh) |*slot| if (slot.* == null) {
+ slot.* = pane;
+ return;
+ };
+ // One update runs one builtin, and the widest drop is a whole column;
+ // 2*MAX_PANES cannot fill in a single frame.
+ unreachable;
+ }
+
+ fn isDoomed(a: *const PaneAllocator, pane: *const Pane) bool {
+ for (a.fresh) |s| if (s == pane) return true;
+ for (a.stale) |s| if (s == pane) return true;
+ return false;
+ }
+};
+
pub const Pardes = struct {
gpa: std.mem.Allocator,
image_gpa: std.mem.Allocator,
@@ -4754,6 +4930,8 @@ pub const Pardes = struct {
resize_count: usize = 0,
panes: [MAX_PANES]?*Pane = @splat(null),
+ /// Deferred teardown of dropped panes (see PaneAllocator).
+ pane_alloc: PaneAllocator = .{},
// layout: columns own x by weight; panes own y by vweight within a column.
ncol: usize = 0,
/// Fixed-point horizontal proportions. Integer sums make splitting W
@@ -4879,11 +5057,6 @@ pub const Pardes = struct {
/// terminal, GPU textures in SDL). Image panes dynamically fall back to
/// the PETSCII matcher without it.
native_images: bool = false,
- /// How the core asks the host the one question about a pane it cannot
- /// answer itself — see `takesCommandLine`, the only caller. Installed once
- /// by the native hosts (and again after a dump replay builds a second
- /// core); left null by the web shell, which has no processes.
- tty_query: ?TtyQuery = null,
quit: bool = false,
/// The Look or Exec that has happened and not yet been felt, taken by the
/// shell once per pump (takeHaptic). A pulse, not a queue: five Execs
@@ -4982,6 +5155,19 @@ pub const Pardes = struct {
effects_head: usize = 0,
effects_len: usize = 0,
+ /// WHO SERVES THIS CORE. Every method optional; a null one is answered by
+ /// `fallback` below, so a `Host{}` is a complete in-process pardes.
+ host: Host = .{},
+ /// The in-program answers behind every unimplemented host method. Per
+ /// instance, so several cores behind one fan-out host stay independent.
+ fallback: Fallback,
+ /// Input the loop has not consumed yet. Single-threaded: a host's worker
+ /// threads keep their own thread-safe inbox and post from the loop thread,
+ /// which is what keeps this ring lock-free.
+ in_q: [64]Event = undefined,
+ in_head: usize = 0,
+ in_len: usize = 0,
+
/// helix's DEFAULT register (gpa-owned): what `y`/`d`/`c` write and
/// `p`/`P`/`R` read. Never the system clipboard — `SPC y`/`SPC p` are the
/// two commands that cross that line.
@@ -4992,6 +5178,9 @@ pub const Pardes = struct {
clip_pending: ?ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
+ /// A terminal scrollback save awaiting the host: bytes + path, gpa-owned,
+ /// read by the save_text effect and cleared once written. At most one.
+ save_text: ?struct { content: []const u8, path: []u8 } = null,
/// where the shell wrote the last dump (shell reports back after
/// write_dump); shown in the topbar as `Restore <path>`
last_dump: ?[]const u8 = null,
@@ -5003,6 +5192,9 @@ pub const Pardes = struct {
surface: Surface = .{},
/// per-update scratch (paneCursorLines, selection text); reset each update
scratch: std.heap.ArenaAllocator,
+ /// The arena each frame's Surface text is built in, reset by `pump`. Hosts
+ /// that still own their loop pass their own arena to `render` instead.
+ frame_arena: std.heap.ArenaAllocator,
/// the terminal motion surface, memoized against the pane it was built
/// for — see term_pane.RowsCache for the lifetime rule
shell_rows: term_pane.RowsCache = .{},
@@ -5021,6 +5213,8 @@ pub const Pardes = struct {
.screen_w = opts.cols,
.screen_h = opts.rows,
.scratch = .init(gpa),
+ .frame_arena = .init(opts.frame_allocator orelse gpa),
+ .fallback = .{ .gpa = gpa },
};
errdefer p.deinit();
if (opts.file) |path| {
@@ -5078,18 +5272,32 @@ pub const Pardes = struct {
pub fn deinit(p: *Pardes) void {
p.cancelLookHover();
for (&p.panes) |*slot| if (slot.*) |pane| {
- p.deinitPane(pane);
+ p.teardownPane(pane);
+ slot.* = null;
+ };
+ for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| {
+ p.teardownPane(pane);
+ slot.* = null;
+ };
+ for (&p.pane_alloc.fresh) |*slot| if (slot.*) |pane| {
+ p.teardownPane(pane);
slot.* = null;
};
const gpa = p.gpa;
if (p.yank) |y| gpa.free(y);
if (p.dump_out) |d| gpa.free(d);
+ if (p.save_text) |s| {
+ gpa.free(s.content);
+ gpa.free(s.path);
+ }
+ p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
if (p.pipe_wait) |*wait| wait.deinit(gpa);
p.shell_rows.reset(gpa);
p.scratch.deinit();
+ p.frame_arena.deinit();
gpa.free(p.surface.cells);
if (p.presented_cells.len > 0) gpa.free(p.presented_cells);
if (p.panel_cell_diffs.len > 0) gpa.free(p.panel_cell_diffs);
@@ -5120,7 +5328,24 @@ pub const Pardes = struct {
return config.topbar_str;
}
+ /// Drop a pane: its slot is freed for reuse now, but the allocation is
+ /// doomed and actually torn down a frame later (reapPanes), so pointers to
+ /// it survive the frame. Callers still null `panes[id]` themselves.
pub fn deinitPane(p: *Pardes, pane: *Pane) void {
+ // Logical close, while the pane is still installed: stop the file/PDF
+ // watch keyed to this slot and drop any hover it owns. The heap
+ // teardown is deferred (reapPanes) so pointers to it survive the frame.
+ const watched = (if (pane.file) |f| f.output == null else false) or hasPdf(pane);
+ if (watched) for (p.panes, 0..) |slot, id| {
+ if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
+ };
+ if (p.lookHoverPane()) |h| if (h < p.panes.len and p.panes[h] == pane) p.cancelLookHover();
+ p.pane_alloc.doom(pane);
+ }
+
+ /// The real teardown, run by reapPanes once the pane has been doomed for a
+ /// full frame (or at deinit). Frees every heap payload the pane owns.
+ fn teardownPane(p: *Pardes, pane: *Pane) void {
if (p.lookHoverPane()) |hovered| {
if (hovered < p.panes.len and p.panes[hovered] == pane)
p.cancelLookHover();
@@ -5140,6 +5365,32 @@ pub const Pardes = struct {
p.gpa.destroy(pane);
}
+ /// Once a frame: repair live panes' pointers to doomed panes, then free the
+ /// panes doomed a full frame ago. Fixup runs first so no pointer outlives
+ /// the memory. `stale` (last frame's dead) is freed; `fresh` becomes stale.
+ fn reapPanes(p: *Pardes) void {
+ for (p.panes) |slot| if (slot) |pane| p.fixupPaneRefs(pane);
+ for (&p.pane_alloc.stale) |*slot| if (slot.*) |pane| {
+ p.teardownPane(pane);
+ slot.* = null;
+ };
+ p.pane_alloc.stale = p.pane_alloc.fresh;
+ p.pane_alloc.fresh = @splat(null);
+ }
+
+ /// Visit each Pane field that holds a pane pointer; when it names a doomed
+ /// pane, snapshot that pane's directory into our own bytes so the link can
+ /// die with it. One field carries a pointer today (the inherited cwd); the
+ /// comptime walk keeps that honest as fields come and go.
+ fn fixupPaneRefs(p: *Pardes, pane: *Pane) void {
+ inline for (@typeInfo(Pane).@"struct".fields) |f| {
+ if (f.type == Pane.Cwd) switch (@field(pane, f.name)) {
+ .inherited => |src| if (p.pane_alloc.isDoomed(src)) pane.setOwnedCwd(paneDir(src)),
+ else => {},
+ };
+ }
+ }
+
/// Put a fully constructed pane in a free slot and give it the monotonic
/// identity every slot-reuse guard relies on. Pane kinds construct their
/// own payloads; this registration rule remains a core invariant.
@@ -5181,6 +5432,38 @@ pub const Pardes = struct {
return pane;
}
+ /// An empty output buffer opened FROM `from_id`: no file behind it, its cwd
+ /// a live link to the opener so Save can prefill that directory. New and
+ /// Newcol place it (below, or in a column). Installed in slot `free`.
+ fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane {
+ const src = p.panes[from_id] orelse return error.MissingPane;
+ const content = try p.gpa.dupe(u8, "");
+ errdefer p.gpa.free(content);
+ const np = try output_pane.open(p, free, paneDir(src), .{ .cmd = .New }, "", content);
+ np.cwd = .{ .inherited = src };
+ np.cur_pinned = true;
+ return np;
+ }
+
+ /// New: a scratch below the calling pane, in its column.
+ pub fn newScratchBelow(p: *Pardes, from_id: usize) void {
+ const free = p.freeSlot() orelse return;
+ const sf = p.layoutFindTerm(from_id) orelse return;
+ const np = p.newScratch(from_id, free) catch return;
+ p.layoutInsert(sf.col, sf.idx + 1, free);
+ p.splitBelow(from_id, np);
+ p.active = free;
+ }
+
+ /// Newcol: a scratch in a fresh column beside the calling pane.
+ pub fn newScratchColumn(p: *Pardes, from_id: usize) void {
+ const free = p.freeSlot() orelse return;
+ if (!p.layoutCanSplitColumn(from_id)) return;
+ _ = p.newScratch(from_id, free) catch return;
+ std.debug.assert(p.layoutSplitColumn(from_id, free, false));
+ p.active = free;
+ }
+
pub fn freeSlot(p: *Pardes) ?usize {
return for (p.panes, 0..) |slot, i| {
if (slot == null) break i;
@@ -5244,11 +5527,14 @@ pub const Pardes = struct {
/// changes it observes, e.g. via /proc/<pid>/cwd before each frame).
pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void {
const pane = p.panes[id] orelse return;
- const n = @min(cwd.len, pane.cwd.len);
- if (pane.cwd_len == n and std.mem.eql(u8, pane.cwd[0..n], cwd[0..n])) return;
+ const n = @min(cwd.len, pane.cwd_buf.len);
+ const cur = switch (pane.cwd) {
+ .owned => |dir| dir,
+ else => "",
+ };
+ if (cur.len == n and std.mem.eql(u8, cur, cwd[0..n])) return;
if (p.lookHoverPane() == id) p.cancelLookHover();
- @memcpy(pane.cwd[0..n], cwd[0..n]);
- pane.cwd_len = @intCast(n);
+ pane.setOwnedCwd(cwd[0..n]);
}
/// Can a command line be typed into this pane RIGHT NOW: a terminal whose
@@ -5273,44 +5559,7 @@ 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;
- const q = p.tty_query orelse return true;
- return !q.taken(q.ctx, id);
- }
-
- /// Complete a `new_file` effect without doing IO in the core. The shell
- /// has already created `path` as an empty file; we model those known empty
- /// bytes directly, insert immediately below the still-live calling pane in
- /// ITS column (never whichever column became active), focus it, and start
- /// the same watch every ordinary file pane has. False leaves ownership
- /// with the shell, which closes and unlinks the unadopted file.
- pub fn openNewFile(p: *Pardes, source: u8, serial: u32, path: []const u8) bool {
- if (path.len == 0 or source >= MAX_PANES) return false;
- const src = p.panes[source] orelse return false;
- if (src.serial != serial) return false;
- const sf = p.layoutFindTerm(source) orelse return false;
- const free = p.freeSlot() orelse return false;
-
- const path_copy = p.gpa.dupe(u8, path) catch return false;
- const content = p.gpa.dupe(u8, "") catch {
- p.gpa.free(path_copy);
- return false;
- };
- const pane = p.newDocPane(free) catch {
- p.gpa.free(content);
- p.gpa.free(path_copy);
- return false;
- };
- pane.file = .{ .path = path_copy, .content = content };
- pane.cur_pinned = true;
-
- // sf was captured from SOURCE, not active. Nothing above this point
- // mutates layout, so it is still the exact calling column/index.
- p.layoutInsert(sf.col, sf.idx + 1, free);
- p.splitBelow(source, pane);
- p.active = free;
- p.emit(.{ .watch = .{ .pane = @intCast(free), .on = true } });
- p.sync();
- return true;
+ return !p.hostTtyTaken(id);
}
/// What a shell should exec for the next terminal — a bare name to be
@@ -5505,8 +5754,8 @@ pub const Pardes = struct {
}
/// Post the transient message on `id`'s last row. Called by a SHELL once
- /// the IO it narrates has actually happened, exactly as openNewFile above
- /// completes a `new_file` effect: the core neither writes files nor owns a
+ /// the IO it narrates has actually happened, exactly as the shell completes
+ /// a `save_file` effect: the core neither writes files nor owns a
/// clock, so both the outcome and the wall time in `text` come from there
/// (message.zig spells it, once, for both native shells).
///
@@ -5586,6 +5835,149 @@ pub const Pardes = struct {
return e;
}
+ /// Queue input for the next `pump`. Single-threaded, and a VALUE queue: an
+ /// event that carries a borrowed slice cannot survive the trip, so this
+ /// asserts rather than documents it. Hand those to `update` directly inside
+ /// the host's borrow window instead — which is also what keeps the pty read
+ /// path copy-free.
+ pub fn postEvent(p: *Pardes, ev: Event) void {
+ switch (ev) {
+ .key => |k| std.debug.assert(k.text.len == 0),
+ .output, .paste, .lsp_resp, .pipe_resp, .file_changed, .command => unreachable,
+ else => {},
+ }
+ if (p.in_len == p.in_q.len) return;
+ p.in_q[(p.in_head + p.in_len) % p.in_q.len] = ev;
+ p.in_len += 1;
+ }
+
+ fn nextQueued(p: *Pardes) ?Event {
+ if (p.in_len == 0) {
+ p.in_head = 0;
+ return null;
+ }
+ const ev = p.in_q[p.in_head];
+ p.in_head = (p.in_head + 1) % p.in_q.len;
+ p.in_len -= 1;
+ return ev;
+ }
+
+ /// Has a program taken this pane's tty? A host that cannot tell says no,
+ /// which is how pardes behaved before the probe existed.
+ fn hostTtyTaken(p: *const Pardes, id: usize) bool {
+ const f = p.host.vtable.pull_tty_taken orelse return false;
+ return f(p.host.ctx, @intCast(id));
+ }
+
+ fn hostWriteFile(p: *Pardes, pane: u8, path: []const u8, bytes: []const u8) void {
+ if (p.host.vtable.push_write_file) |f| return f(p.host.ctx, pane, path, bytes);
+ p.fallback.writeFile(path, bytes);
+ }
+
+ /// The path a watch is about: a real file's, or a PDF's.
+ fn watchPath(p: *const Pardes, id: u8) ?[]const u8 {
+ const pane = p.panes[id] orelse return null;
+ if (pane.file) |f| return if (f.output == null) f.path else null;
+ return pane.pdfPath();
+ }
+
+ /// Perform one effect through the host, falling back per METHOD (not per
+ /// host) to the in-process implementation. This is the switch that used to
+ /// be copied into all four shells.
+ pub fn perform(p: *Pardes, e: Effect) void {
+ const v = p.host.vtable;
+ switch (e) {
+ .spawn => |s| if (v.push_spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
+ p.fallback.spawned[s.pane] = true;
+ },
+ // A pane with no child is silent: nothing invents output on its
+ // screen, and the bytes are dropped rather than transcribed.
+ .write => |w| if (v.push_pty_write) |f| f(p.host.ctx, w.pane, w.bytes.slice()),
+ .resize_pty => |r| if (v.push_pty_resize) |f| f(p.host.ctx, r.pane, r.cols, r.rows),
+ .open_link => |u| if (v.push_open_link) |f|
+ f(p.host.ctx, u.slice())
+ else
+ p.fallback.setLink(u.slice()),
+ .save_file => |sf| {
+ const pane = p.panes[sf.pane] orelse return;
+ const f = pane.file orelse return;
+ p.hostWriteFile(sf.pane, f.path, f.content);
+ },
+ .save_text => |st| {
+ const s = p.save_text orelse return;
+ p.hostWriteFile(st.pane, s.path, s.content);
+ p.clearSaveText();
+ },
+ .write_dump => {
+ const out = p.dump_out orelse return;
+ if (v.push_write_dump) |f| {
+ f(p.host.ctx, out);
+ } else {
+ // A real host reports where it landed, which is what puts
+ // `Restore <path>` in the topbar; the virtual one owes the
+ // same, or the bytes it holds are unreachable.
+ p.fallback.writeFile(fallback_dump_path, out);
+ p.setLastDump(fallback_dump_path);
+ }
+ },
+ .set_clipboard => {
+ const text = p.yank orelse "";
+ if (v.push_set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text);
+ },
+ // No desktop to ask: answer from the in-process clipboard at once,
+ // which is the same shape as a host answering later.
+ .read_clipboard => if (v.pull_read_clipboard) |f|
+ f(p.host.ctx)
+ else
+ p.update(.{ .paste = p.fallback.clipboard.items }),
+ .lsp => |q| if (v.pull_lsp) |f|
+ f(p.host.ctx, .{ .id = q.id, .kind = q.kind, .pane = q.pane, .offset = q.offset, .arg = q.arg.slice() })
+ else
+ p.update(.{ .lsp_resp = .{ .id = q.id, .rows = "" } }),
+ .pipe => |q| if (v.pull_pipe) |f|
+ f(p.host.ctx, q.id)
+ else
+ p.update(.{ .pipe_resp = .{ .id = q.id, .success = false, .outputs = &.{} } }),
+ .watch => |w| if (v.push_watch_file) |f|
+ f(p.host.ctx, w.pane, p.watchPath(w.pane) orelse "", w.on)
+ else {
+ p.fallback.watched[w.pane] = w.on;
+ },
+ .theme_file => |t| if (v.push_watch_theme) |f| f(p.host.ctx, t.generation, t.on),
+ .dump_themes => |d| if (v.push_dump_themes) |f| f(p.host.ctx, d.pane),
+ // the loop's own condition; a host tears down after its own loop
+ .quit => p.quit = true,
+ }
+ }
+
+ /// ONE ITERATION OF THE LOOP, and the reason the core owns it: the ORDER
+ /// here — wait, apply input, perform effects, poll, render, present — was
+ /// copied into four shells and drifted in each. A host supplies the parts
+ /// only it can (blocking, pixels, processes) and nothing else.
+ ///
+ /// It is one PUMP and never a `while`: no host gives up its outer loop.
+ /// AppKit owns NSApplication's run loop, the browser owns the frame
+ /// callback, and both Linux hosts keep a thin one so Restore can swap the
+ /// whole core between frames.
+ pub fn pump(p: *Pardes, h: Host) !void {
+ p.host = h;
+ const v = h.vtable;
+ if (v.pull_wait_input) |f| f(h.ctx, if (p.animationActive()) animation.frame_ms else 0);
+ while (p.nextQueued()) |ev| p.update(ev);
+ while (p.nextEffect()) |e| p.perform(e);
+ // A quitting frame has already freed what it would draw.
+ if (p.quit) return;
+ if (v.push_poll_frame) |f| f(h.ctx);
+ _ = p.frame_arena.reset(.retain_capacity);
+ const surface = try p.render(p.frame_arena.allocator());
+ if (v.push_present) |f| f(h.ctx, surface);
+ if (v.push_post_present) |f| f(h.ctx);
+ // Animation TIME is not spent here. `wait_input` was told how long it
+ // may sleep; a display clock wakes faster than that on input, so only
+ // the host knows when a real frame interval has passed. Each spends it
+ // by handing back one `.tick`.
+ }
+
pub fn update(p: *Pardes, ev: Event) void {
// Free a motion surface that went bad during the LAST update, before
// anything in this one can ask for it. Nothing frees it mid-update:
@@ -5785,6 +6177,26 @@ pub const Pardes = struct {
p.emit(.read_clipboard);
}
+ /// Type text at a pane's program, the way a terminal emulator pastes:
+ /// bracketed when the app set mode 2004 (readline/vim/helix strip the
+ /// markers and refuse to run what arrives), else with `\n` turned to `\r`,
+ /// because a raw newline in an unbracketed paste IS the Enter key and a
+ /// multi-line paste would run every line but the last.
+ pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void {
+ if (text.len == 0) return;
+ if (pane.vt.modes.get(.bracketed_paste)) {
+ p.emitWrite(id, "\x1b[200~");
+ p.emitWrite(id, text);
+ p.emitWrite(id, "\x1b[201~");
+ return;
+ }
+ const cp = p.scratch.allocator().dupe(u8, text) catch return;
+ for (cp) |*ch| {
+ if (ch.* == '\n') ch.* = '\r';
+ }
+ p.emitWrite(id, cp);
+ }
+
/// The shell answered with system-clipboard text — or the desktop pasted
/// into us unasked. Either way the bytes are pasted WITHOUT going through
/// the register: helix's clipboard commands and the default register are
@@ -5798,6 +6210,11 @@ pub const Pardes = struct {
const pane = p.panes[id] orelse return;
if (req) |r| if (pane.serial != r.serial) return;
p.active = id;
+ // A pane in tty mode has no editable buffer to paste INTO — the pty
+ // owns its screen. Type the bytes at the program instead, which is
+ // also what makes a desktop paste (Ctrl-Shift-V, middle click, the
+ // window manager's own) reach a shell at all.
+ if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes);
switch (if (req) |r| r.mode else .after) {
.after => p.pasteText(pane, bytes, false),
.before => p.pasteText(pane, bytes, true),
@@ -6645,6 +7062,15 @@ pub const Pardes = struct {
} else term_pane.enterTty(p, p.active);
return;
}
+ // 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);
+ }
// `|` owns the same visible one-line tag input as search, but Enter
// snapshots an asynchronous shell filter. Escape is a pure cancel:
// restore the old tail and never emit a request.
@@ -6657,6 +7083,19 @@ pub const Pardes = struct {
pane.pending = 0;
return;
}
+ // a save input in flight (scratch or terminal): Enter writes the path,
+ // Esc abandons; both drop the prompt text and return to the body.
+ if (pane.hasSavePrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) {
+ const prompt_at = pane.promptAt().?;
+ if (hit(key, config.search_submit))
+ p.submitSave(p.active)
+ else
+ pane.tag_tail_len = @min(prompt_at, pane.tag_tail_len);
+ exitTagEdit(pane);
+ pane.mode = .normal;
+ pane.pending = 0;
+ return;
+ }
// a search input in flight (`/` or Find): Enter searches, Esc abandons;
// both restore the tag tail and hand focus back to the body.
if (pane.hasSearchPrompt() and (hit(key, config.search_submit) or hit(key, config.escape))) {
@@ -8366,6 +8805,12 @@ pub const Pardes = struct {
const SearchStart = enum { top, cursor };
fn paneDir(pane: *const Pane) []const u8 {
+ // an inherited cwd is a live link that outranks a scratch buffer's own
+ // synthetic path: follow it to the pane it was opened from.
+ switch (pane.cwd) {
+ .inherited => |src| return paneDir(src),
+ else => {},
+ }
if (pane.file) |f| return std.fs.path.dirname(f.path) orelse "/";
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fs.path.dirname(pv.path) orelse "/";
@@ -8390,6 +8835,89 @@ pub const Pardes = struct {
pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
}
+ /// Save on a scratch buffer or a terminal arms a PATH input in the tag,
+ /// prefilled with the pane's directory (an inherited scratch follows the
+ /// pane it was opened from). submitSave writes it.
+ pub fn startSavePrompt(p: *Pardes, pane: *Pane) void {
+ p.seedTail(pane);
+ if (!pane.tag_init) return;
+ const prompt_at: u16 = @intCast(pane.tag_tail_len);
+ if (!pane.appendTag(config.save_marker)) return;
+ const dir = paneDir(pane);
+ _ = pane.appendTag(dir);
+ if (dir.len == 0 or dir[dir.len - 1] != '/') _ = pane.appendTag("/");
+ pane.prompt = .{ .save = prompt_at };
+ pane.tag_edit = true;
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
+ }
+
+ /// Enter on a save input: the path is everything past the marker.
+ fn submitSave(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const at = switch (pane.prompt) {
+ .save => |a| a,
+ else => return,
+ };
+ const armed = pane.tag_tail[at..pane.tag_tail_len];
+ pane.tag_tail_len = at; // drop the prompt back to the seeded tail
+ if (!std.mem.startsWith(u8, armed, config.save_marker)) return;
+ const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n");
+ if (path.len == 0) return;
+ p.performSave(id, path);
+ }
+
+ /// Commit a prompted path. A scratch buffer's own bytes go there and it
+ /// BECOMES an ordinary file pane; a terminal's plaintext scrollback goes
+ /// there and it stays a terminal.
+ fn performSave(p: *Pardes, id: usize, path: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.file) |*f| {
+ const owned = p.gpa.dupe(u8, path) catch return;
+ p.gpa.free(f.path);
+ f.path = owned;
+ f.output = null; // an ordinary file pane from here on
+ pane.cwd = .none; // its directory is now its own path's dirname
+ f.saved_revision = f.revision;
+ pane.tag_init = false; // re-derive the tag as a plain file
+ pane.tag_tail_len = 0;
+ p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
+ return;
+ }
+ p.saveScrollback(id, path);
+ }
+
+ /// A terminal's plaintext scrollback to `path`, leaving the pane a terminal.
+ /// The core cannot do IO, so it stashes the bytes and asks the host to
+ /// write them (save_text) the same way save_file hands off a file pane.
+ fn saveScrollback(p: *Pardes, id: usize, path: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal()) return;
+ const content = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return;
+ const owned = p.gpa.dupe(u8, path) catch {
+ p.gpa.free(content);
+ return;
+ };
+ if (p.save_text) |old| {
+ p.gpa.free(old.content);
+ p.gpa.free(old.path);
+ }
+ p.save_text = .{ .content = content, .path = owned };
+ p.emit(.{ .save_text = .{ .pane = @intCast(id) } });
+ }
+
+ /// The host has consumed the pending scrollback save: free and clear it.
+ pub fn clearSaveText(p: *Pardes) void {
+ if (p.save_text) |s| {
+ p.gpa.free(s.content);
+ p.gpa.free(s.path);
+ }
+ p.save_text = null;
+ }
+
/// Enter on an armed input: the pattern is everything past the marker's
/// `/` (so a pattern may itself contain slashes), and the marker names the
/// search — " Find /" walks the filesystem for NAMES, " Grep /" for
@@ -11466,17 +11994,7 @@ pub const Pardes = struct {
p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
}
}
- if (m.get(.bracketed_paste)) {
- p.emitWrite(s.id, "\x1b[200~");
- p.emitWrite(s.id, y);
- p.emitWrite(s.id, "\x1b[201~");
- } else {
- const cp = p.scratch.allocator().dupe(u8, y) catch return;
- for (cp) |*c| {
- if (c.* == '\n') c.* = '\r';
- }
- p.emitWrite(s.id, cp);
- }
+ p.typeToTty(s.id, pane, y);
return;
}
if (!s.chorded) {
@@ -11568,6 +12086,40 @@ pub const Pardes = struct {
}
}
+ /// Newtty: 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 {
+ 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.mode = .tty;
+ const parent = p.splitParent(from);
+ const f = p.layoutFindTerm(parent).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.splitBelow(parent, nt);
+ p.active = free;
+ }
+
+ /// Joincol: fold the active pane's column into the one on its right,
+ /// carrying its panes and width across. Inert without a right neighbour.
+ pub fn joinCol(p: *Pardes) void {
+ const f = p.layoutFindTerm(p.active) orelse return;
+ const c = f.col;
+ if (c + 1 >= p.ncol) return;
+ const dst = c + 1;
+ p.col_weight[dst] +|= p.col_weight[c];
+ for (0..p.col_n[c]) |k| p.col_terms[dst][p.col_n[dst] + k] = p.col_terms[c][k];
+ p.col_n[dst] += p.col_n[c];
+ var j = c;
+ while (j + 1 < p.ncol) : (j += 1) {
+ p.col_terms[j] = p.col_terms[j + 1];
+ p.col_n[j] = p.col_n[j + 1];
+ p.col_weight[j] = p.col_weight[j + 1];
+ }
+ p.ncol -= 1;
+ }
+
/// Whether `source_id` can donate half its width to a new column. This is
/// public so callers that must create a pane first can reject before that
/// creation emits any native-side work.
@@ -12274,6 +12826,15 @@ pub const Pardes = struct {
/// left, shells right). Only the calling/source column donates half its
/// width; every other column keeps its boundary. A full column bar, or a
/// result list whose source died, stacks into the leftmost.
+ /// A new file opens a column only when the split leaves both sides wide
+ /// enough to read; otherwise it stacks as a pane. Halving is exact, so the
+ /// narrower side is floor(width/2) >= min_cells iff width >= 2*min_cells.
+ fn columnFitsHalves(p: *Pardes, source_id: usize, min_cells: u16) bool {
+ const f = p.layoutFindTerm(source_id) orelse return false;
+ p.computeGeom();
+ return p.col_w[f.col] >= min_cells * 2;
+ }
+
pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
const doc = if (nt.file) |f| output_pane.fileTraits(f.output).doc else true; // an image is a doc
var src_id: ?usize = null;
@@ -12335,7 +12896,7 @@ pub const Pardes = struct {
p.active = free;
return;
}
- if (doc and p.ncol < MAX_COLS) {
+ if (doc and p.ncol < MAX_COLS and p.columnFitsHalves(from_id, 100)) {
if (!p.layoutSplitColumn(from_id, free, true)) return p.stackDocLeft(free, nt);
p.active = free;
return;
@@ -12437,6 +12998,8 @@ pub const Pardes = struct {
.screen_w = opts.cols,
.screen_h = opts.rows,
.scratch = .init(gpa),
+ .frame_arena = .init(opts.frame_allocator orelse gpa),
+ .fallback = .{ .gpa = gpa },
};
errdefer p.deinit();
const st = try dump.readZon(gpa, zon_bytes, "load");
@@ -12586,12 +13149,12 @@ pub const Pardes = struct {
// content, and a default that came back padded is still a default.
const said = std.mem.trimStart(u8, rest, " ");
const defaults: []const []const u8 = switch (class) {
- .generic => &.{ pane_tail, legacy_pane_tail },
- .savable_file => &.{ file_pane_tail, legacy_file_pane_tail },
+ .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail },
+ .savable_file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
// Terminal dumps written before Filter used the generic current
- // tail, and the earliest version used just Del. Both upgrade;
- // other pane classes still treat this new default as user text.
- .terminal => &.{ terminal_pane_tail, pane_tail, legacy_pane_tail },
+ // tail, and the earliest version used just Del. All upgrade; other
+ // pane classes still treat these as user text.
+ .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail },
};
for (defaults) |default|
if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;
@@ -12740,6 +13303,7 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
fn sync(p: *Pardes) void {
+ p.reapPanes();
p.computeGeom();
p.syncPanelAnimations();
p.trackJump();
@@ -13836,18 +14400,22 @@ pub const Pardes = struct {
_ = s.print(tx, body_y + i, tw, line, body_style);
}
- // the file-only passes and the terminal-only one: each is the
- // whole reason its pane kind renders differently at all, so each lives
- // with its kind. Order is load-bearing — gutter, then syntax over the
- // content, then the emulator's own colors — and the selection/cursor
- // passes below still win over all three.
- if (pane.file) |*f| {
- file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
- if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
- file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg);
+ // Coloring is one algorithm per pane, chosen by title (colorAlgo): the
+ // terminal projects its own ANSI, a file lays tree-sitter or diff
+ // shading over its content. source and diff share this pass because
+ // both feed f.highlights, which refreshHighlights filled with whichever
+ // this same choice named. Order is load-bearing — gutter, recolor, then
+ // wrap markers; the selection/cursor passes below win over all three.
+ switch (pane.colorAlgo()) {
+ .tty => if (p.settings.colors and pane.mode == .tty) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h),
+ .source, .diff => {
+ const f = &pane.file.?;
+ file_pane.drawGutter(p, pane, r, tx, tw, body_h, active);
+ if (p.settings.colors) file_pane.recolorSyntax(p, pane, f, r, tx, tw, body_h);
+ file_pane.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg);
+ },
+ .none => {},
}
- const off = pane.scroll();
- if (p.settings.colors and pane.isTerminal()) term_pane.recolorAnsi(p, pane, r, tx, tw, body_h, off);
// mouse selections (pane-local coords), one pass per button — later
// buttons win on overlap. A left .done stays highlighted after release;
@@ -14363,6 +14931,81 @@ test "only the SPC clipboard commands cross to the system clipboard" {
try std.testing.expect(p.clip_pending == null);
}
+/// Every `.write` a keystroke produced, joined — what the pane's program would
+/// actually have read off its pty.
+fn drainWrites(p: *Pardes, buf: []u8) []const u8 {
+ var n: usize = 0;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |w| {
+ const b = w.bytes.slice();
+ if (n + b.len > buf.len) break;
+ @memcpy(buf[n..][0..b.len], b);
+ n += b.len;
+ },
+ else => {},
+ };
+ return buf[0..n];
+}
+
+test "Ctrl-V and Ctrl-Shift-V paste into the program a tty pane is running" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ var buf: [256]u8 = undefined;
+ _ = drainWrites(p, &buf);
+
+ const pane = p.panes[0].?;
+ term_pane.enterTty(p, 0);
+ try std.testing.expectEqual(Mode.tty, pane.mode);
+ p.setYank("one\ntwo");
+
+ // Ctrl-V types the DEFAULT REGISTER at the program. Unbracketed, so the
+ // newline becomes Enter's \r — a raw \n would run `one` and leave `two`
+ // half-typed.
+ 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.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.vt.modes.set(.bracketed_paste, false);
+ 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 "");
+}
+
+test "an unasked desktop paste reaches a tty pane's program, not its buffer" {
+ if (platform == .web) return;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ var buf: [256]u8 = undefined;
+ _ = drainWrites(p, &buf);
+
+ const pane = p.panes[0].?;
+ term_pane.enterTty(p, 0);
+ // No request behind it: the window manager's own paste, or SDL answering a
+ // Ctrl-Shift-V the desktop handled. It still has to reach the shell.
+ p.update(.{ .paste = "ls -la" });
+ try std.testing.expectEqualStrings("ls -la", drainWrites(p, &buf));
+ // and nothing was typed into an overlay the pty does not know about
+ try std.testing.expect(pane.ovl == null);
+}
+
test "leaving tty hides the prompt and keeps the command typed at it" {
if (platform == .web) return;
const gpa = std.testing.allocator;