summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 20:35:01 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitc3e2cd3b854964ca7db3798cb8750ee6d0833057 (patch)
tree0073c6bb6ec4cd78fffa89b2880dd83b4a106e1d /src
parent5d9a56d47a9cd5eefc4c8bf03709f2e96febb212 (diff)
downloadpardes-c3e2cd3b854964ca7db3798cb8750ee6d0833057.tar.gz
pardes-c3e2cd3b854964ca7db3798cb8750ee6d0833057.zip
pty/run says cut when its output's start is gone, and reads only a bounded tail
A clear or reset while the command ran, a start or end mark that came on the alternate screen, or a garbage end pin read as a complete answer; now any of them, like a start that scrolled out, answers exit N cut. The output is read from at most output_rows rows above its end instead of the whole history on the editor's thread (cut if that clips it), a D with no status answers exit ? rather than exit 0, and running out of memory answers error out of memory rather than cut. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ninep/pty.zig153
-rw-r--r--src/panes.zig62
2 files changed, 180 insertions, 35 deletions
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 706163e5..936f9b73 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -113,6 +113,11 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
/// bytes, since that is where a failure says what went wrong.
pub const output_cap = 64 * 1024;
+/// The most rows above a command's end its output is read from: `output_cap`
+/// at eight bytes a row. The answer is made on the editor's thread, which
+/// would otherwise render a command's whole 16 MiB of history to keep 64 KiB.
+pub const output_rows = output_cap / 8;
+
/// What an open of run holds (tree.zig's open records).
pub const Run = struct {
phase: enum { idle, sent, done } = .idle,
@@ -258,34 +263,35 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
- const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa) catch null;
- defer if (printed) |text| p.gpa.free(text);
- const scrolled_out = if (state.stream.handler.out_start) |pin| pin.garbage else false;
+ pf.run = null;
+ const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch
+ return answer(p, slot, "error out of memory", .{});
+ defer p.gpa.free(printed.text);
// Keep the tail, cut at a line start so the first line kept is whole.
- var keep: []const u8 = printed orelse "";
+ var keep: []const u8 = printed.text;
if (keep.len > output_cap) {
keep = keep[keep.len - output_cap ..];
if (std.mem.indexOfScalar(u8, keep, '\n')) |nl| keep = keep[nl + 1 ..];
}
- const cut = (if (printed) |text| text.len else 0) - keep.len;
- const status = done.status orelse 0;
- // The header is the whole first line, so a count there can never be
- // mistaken for output; `cut` with no count: its start scrolled away.
- if (printed == null or scrolled_out)
- answer(p, slot, "exit {d} cut", .{status})
- else if (cut > 0)
- answer(p, slot, "exit {d} cut {d}", .{ status, cut })
- else
- answer(p, slot, "exit {d}", .{status});
if (keep.len > 0) {
const nl = @intFromBool(keep[keep.len - 1] != '\n');
- if (p.gpa.alloc(u8, keep.len + nl)) |owned| {
- @memcpy(owned[0..keep.len], keep);
- if (nl == 1) owned[keep.len] = '\n';
- slot.output = owned;
- } else |_| {}
+ const owned = p.gpa.alloc(u8, keep.len + nl) catch return answer(p, slot, "error out of memory", .{});
+ @memcpy(owned[0..keep.len], keep);
+ if (nl == 1) owned[keep.len] = '\n';
+ slot.output = owned;
}
- pf.run = null;
+ // A D that carries no status says nothing of how the command went.
+ var code: [16]u8 = undefined;
+ const status = if (done.status) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
+ // The header is the whole first line, so a count there can never be
+ // mistaken for output; `cut` with no count: its start is not there to
+ // count from.
+ if (!printed.whole)
+ answer(p, slot, "exit {s} cut", .{status})
+ else if (printed.text.len > keep.len)
+ answer(p, slot, "exit {s} cut {d}", .{ status, printed.text.len - keep.len })
+ else
+ answer(p, slot, "exit {s}", .{status});
}
/// The pane closed or its shell was replaced: the command's end will never
@@ -687,6 +693,113 @@ test "run answers how its command ended once the shell is back at a prompt" {
for (p.fs.opens) |o| try testing.expect(o.node == 0);
}
+test "a run's answer says cut when its output's start is gone, and reads a bounded tail" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .pty_run);
+ const pane = p.panes[0].?;
+ const sh = struct {
+ fn put(pp: *Pardes, bytes: []const u8) void {
+ pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } });
+ while (pp.nextEffect()) |_| {}
+ }
+ fn send(pp: *Pardes, n: u64) u32 {
+ const h = call(pp, .{ .tag = 1, .op = .open, .node = n }).reply.handle;
+ _ = call(pp, .{ .tag = 2, .op = .write, .node = n, .handle = h, .data = "x\n" });
+ return h;
+ }
+ /// The whole answer, then the open is closed.
+ fn answered(pp: *Pardes, n: u64, h: u32, into: []u8) []const u8 {
+ defer _ = call(pp, .{ .tag = 4, .op = .release, .node = n, .handle = h });
+ var len: usize = 0;
+ while (true) {
+ const a = call(pp, .{ .tag = 3, .op = .read, .node = n, .handle = h, .size = 8192 });
+ if (a.reply.status != .ok or a.bytes.len == 0) return into[0..len];
+ @memcpy(into[len..][0..a.bytes.len], a.bytes);
+ len += a.bytes.len;
+ }
+ }
+ };
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ const c = "\r\n\x1b]133;C;aid=pardes\x07";
+ const d = "\x1b]133;D;0;aid=pardes\x07";
+ const into = try gpa.alloc(u8, 2 * output_cap);
+ defer gpa.free(into);
+ sh.put(p, prompt);
+
+ // `clear` while it runs erases what it printed before, and the pins then
+ // span rows that no longer hold it (ED 3 even moves one to the next page
+ // without calling it garbage), so only the erase itself tells.
+ var h = sh.send(p, node);
+ sh.put(p, c ++ "before\r\n\x1b[H\x1b[2J\x1b[3Jafter\r\n" ++ d ++ prompt);
+ var got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+
+ // A resize between its start and end reflows the pins with the text.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "one\r\ntwo\r\n");
+ pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20);
+ sh.put(p, d ++ prompt);
+ try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into));
+ pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows);
+
+ // A full-screen program leaves nothing on the primary screen, and a D
+ // with no status is not a success.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "\x1b[?1049hwhole screen\x1b[?1049l\x1b]133;D;aid=pardes\x07" ++ prompt);
+ try testing.expectEqualStrings("exit ?\n", sh.answered(p, node, h, into));
+
+ // A megabyte of output: read from at most `output_rows` rows above its
+ // end (the history still holds its start), the last 64 KiB kept.
+ h = sh.send(p, node);
+ sh.put(p, c);
+ var line: [120]u8 = @splat('y');
+ line[118] = '\r';
+ line[119] = '\n';
+ for (0..(1 << 20) / line.len) |i| {
+ _ = std.fmt.bufPrint(&line, "{d:0>8}", .{i}) catch unreachable;
+ sh.put(p, &line);
+ }
+ try testing.expect(!pane.terminal.?.stream.handler.out_start.?.garbage);
+ sh.put(p, d ++ prompt);
+ got = sh.answered(p, node, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\n"));
+ try testing.expect(got.len <= "exit 0 cut\n".len + output_cap + 1);
+ try testing.expect(std.mem.endsWith(u8, got, "\n") and std.mem.indexOf(u8, got, "00008737") != null);
+
+ // Its start pruned from a history held to its least: what survived, and
+ // cut.
+ {
+ const q = try withTerm(gpa);
+ defer q.deinit();
+ const kept = q.panes[0].?;
+ kept.terminal.?.vt.screens.get(.primary).?.pages.explicit_max_size = 1;
+ const run = Node.of(serialOf(q), .pty_run);
+ sh.put(q, prompt);
+ h = sh.send(q, run);
+ sh.put(q, c);
+ const lines = "pruned\r\n" ** 64;
+ var fed: usize = 0;
+ while (!kept.terminal.?.stream.handler.out_start.?.garbage) : (fed += 1) {
+ if (fed == 1000) return error.TestUnexpectedResult;
+ sh.put(q, lines);
+ }
+ sh.put(q, d ++ prompt);
+ got = sh.answered(q, run, h, into);
+ try testing.expect(std.mem.startsWith(u8, got, "exit 0 cut\npruned\n"));
+ }
+
+ // No memory for the answer's output is said, not passed off as a cut.
+ h = sh.send(p, node);
+ sh.put(p, c ++ "short\r\n");
+ var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
+ p.gpa = failing.allocator();
+ sh.put(p, d ++ prompt);
+ p.gpa = gpa;
+ try testing.expectEqualStrings("error out of memory\n", sh.answered(p, node, h, into));
+}
+
test "the pty queue drops the oldest at its cap" {
const gpa = testing.allocator;
const p = try withTerm(gpa);
diff --git a/src/panes.zig b/src/panes.zig
index 08310830..0fb49636 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -6535,6 +6535,11 @@ pub const Terminal = struct {
/// D) on the primary screen, pinned so scrolling keeps them.
out_start: ?*ghostty_vt.Pin = null,
out_end: ?*ghostty_vt.Pin = null,
+ /// The running command's output was erased under it -- a clear, ED 3,
+ /// a reset -- so the pins no longer span what it printed: ED 3 moves
+ /// a pin to the next page without flagging it `garbage`
+ /// (PageList.erasePage).
+ lost: bool = false,
/// The newest command to end: which one, and its status if given.
finished: ?struct { seq: u32, status: ?i32 } = null,
@@ -6569,6 +6574,7 @@ pub const Terminal = struct {
.end_input_start_output => if (self.phase == .input) {
self.phase = .running;
self.started +%= 1;
+ self.lost = false;
self.pinCursor(&self.out_start, true);
self.pinCursor(&self.out_end, false);
},
@@ -6582,6 +6588,9 @@ pub const Terminal = struct {
else => {},
}
}
+ if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) {
+ if (self.phase == .running) self.lost = true;
+ }
self.inner.vt(action, value);
}
@@ -7380,23 +7389,46 @@ pub const Terminal = struct {
state.vt.screens.active.cursor.semantic_content == .input;
}
+ pub const CommandOutput = struct {
+ /// Owned by the caller.
+ text: [:0]const u8,
+ /// Whether `text` starts where the command did. It does not when that
+ /// start is not there to read: cut off by `max_rows`, scrolled out of
+ /// the history (ghostty moved the pin to the oldest row it kept and
+ /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or
+ /// never pinned (a C or D that came on the alternate screen).
+ whole: bool,
+ };
+
/// What the newest finished command printed: the screen text between its
- /// C and D marks, owned by the caller. When its start has scrolled out of
- /// the history, ghostty moved that pin to the oldest row it kept (and
- /// flagged it `garbage`), so this is the part that survived.
- pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator) ![:0]const u8 {
- if (comptime enabled) printed: {
- const state = pane.terminal orelse break :printed;
- const start = state.stream.handler.out_start orelse break :printed;
- const end = state.stream.handler.out_end orelse break :printed;
- if (end.garbage) break :printed;
- const screen = state.vt.screens.get(.primary) orelse break :printed;
+ /// C and D marks, read from at most `max_rows` rows above its end, so a
+ /// command that printed the whole history costs no more than its tail.
+ pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
+ // Unless there is text to read, whether the nothing it printed is the
+ // whole of it.
+ const whole = if (comptime enabled) printed: {
+ const state = pane.terminal orelse break :printed true;
+ const marks = &state.stream.handler;
+ const start = marks.out_start orelse break :printed false;
+ const end = marks.out_end orelse break :printed false;
+ const screen = state.vt.screens.get(.primary) orelse break :printed false;
+ if (end.garbage) break :printed false;
+ var whole = !marks.lost and !start.garbage;
// D is where the next prompt begins: the output ends a cell before.
- const last = end.leftWrap(1) orelse break :printed;
- if (start.eql(end.*) or last.before(start.*)) break :printed;
- return try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(start.*, last, false) });
- }
- return try gpa.dupeZ(u8, ""); // it printed nothing
+ const last = end.leftWrap(1) orelse break :printed whole;
+ if (start.eql(end.*) or last.before(start.*)) break :printed whole;
+ var from = start.*;
+ if (last.up(max_rows)) |floor| if (from.before(floor)) {
+ from = floor;
+ from.x = 0;
+ whole = false;
+ };
+ return .{
+ .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }),
+ .whole = whole,
+ };
+ } else true;
+ return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole };
}
/// At a prompt with nothing typed on its line: no input left of the