summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/fs-help.txt2
-rw-r--r--src/fs.zig1
-rw-r--r--src/ninep/events.zig11
-rw-r--r--src/ninep/pty.zig75
-rw-r--r--src/ninep/tree.zig6
-rw-r--r--src/panes.zig37
6 files changed, 112 insertions, 20 deletions
diff --git a/src/fs-help.txt b/src/fs-help.txt
index cc682cf7..5f890d66 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -30,7 +30,7 @@ Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
cat $m/pane/$n/event hold it open to own that pane's clicks
echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT
- exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N, busy or error, once back at a prompt
+ exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy, or error
printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy
Pitfalls, one each:
diff --git a/src/fs.zig b/src/fs.zig
index c35322b6..8e2c52a1 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1262,6 +1262,7 @@ pub const Namespace = struct {
for (st.os_paths.items) |entry| gpa.free(entry.path);
st.os_paths.deinit(gpa);
for (st.snapshots) |snapshot| if (snapshot.bytes) |bytes| gpa.free(bytes);
+ for (&st.runs) |*slot| slot.reset(gpa, 0);
for (&st.panes) |*pf| pf.deinit(gpa);
st.log.deinit(gpa);
st.out.deinit(gpa);
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 20cdeda4..bd5fecf3 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -209,10 +209,12 @@ pub fn readLog(p: *Pardes, req: Req) Reply {
const frozen = slot.bytes orelse &.{};
const out = p.fs.stage(p.gpa);
if (!slot.follow or slot.pos < frozen.len) {
- const off = @min(slot.pos, frozen.len);
+ // Until `follow`, the frozen text is a file like any other: `tail -n`
+ // seeks near its end, `less` seeks back. After it, a stream.
+ const off = @min(if (slot.follow) slot.pos else req.off, frozen.len);
const len = @min(frozen.len - off, req.size);
out.appendSlice(p.gpa, frozen[off..][0..len]) catch return Reply.fail(req.tag, E.NOMEM);
- slot.pos += len;
+ slot.pos = off + len;
return .{ .tag = req.tag, .payload = .{ .staged = @intCast(len) } };
}
const q = &p.fs.log;
@@ -671,6 +673,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
const past = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len, .size = 8192 });
try testing.expectEqual(Status.ok, past.reply.status);
try testing.expectEqual(@as(usize, 0), past.bytes.len);
+ const tail = call(p, .{ .tag = 3, .op = .read, .node = log, .handle = plain.reply.handle, .off = first.bytes.len - early_owned.len, .size = 8192 });
+ try testing.expectEqualStrings(early_owned, tail.bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = log, .handle = plain.reply.handle });
// `follow` on an open makes it wait past what it froze, one record a read.
@@ -680,7 +684,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
try testing.expectEqual(Status.err, call(p, .{ .tag = 7, .op = .write, .node = log, .handle = fh, .data = "tail\n" }).reply.status);
try testing.expectEqual(Status.ok, call(p, .{ .tag = 8, .op = .write, .node = log, .handle = fh, .data = "follow\n" }).reply.status);
// A stream: after `echo follow >&3` a shell reads at offset 7, and gets
- // what comes next, not the frozen text from byte 7.
+ // what comes next, not the frozen text from byte 7. Before `follow` the
+ // offset is honoured, so `tail -n` works (checked below on `plain`).
const rdf = struct {
fn next(pp: *Pardes, node: u64, h: u32, off: u64) th.Answer {
return call(pp, .{ .tag = 9, .op = .read, .node = node, .handle = h, .off = off, .size = 8192 });
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index ec2e18e5..a689d87f 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -111,6 +111,10 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
pub const run_slots = 32;
+/// The most of a command's output an answer carries: its last this-many
+/// bytes, since that is where a failure says what went wrong.
+pub const output_cap = 64 * 1024;
+
pub const Run = struct {
/// 0 is a free slot.
node: u64 = 0,
@@ -129,9 +133,17 @@ pub const Run = struct {
/// `exec 3<>run` shares one offset between the write and the read, so
/// the offset a read arrives with means nothing (acme's event file and
/// Plan 9's kprint read the same way).
- read: u8 = 0,
+ read: usize = 0,
+ /// The first line: how it ended.
answer: [48]u8 = undefined,
len: u8 = 0,
+ /// Then what the command printed, gpa-owned.
+ output: []u8 = &.{},
+
+ pub fn reset(slot: *Run, gpa: std.mem.Allocator, node: u64) void {
+ gpa.free(slot.output);
+ slot.* = .{ .node = node };
+ }
};
fn runOf(p: *Pardes, req: Req) ?*Run {
@@ -158,9 +170,10 @@ fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void {
pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL);
- if (slot.phase == .sent) return tree.failText(req.tag, 16, tree.e_in_use);
- slot.* = .{ .node = slot.node }; // a new line on this open: a new answer
+ if (slot.phase == .sent) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
+ slot.reset(p.gpa, slot.node); // a new line on this open: a new answer
const line = std.mem.trimEnd(u8, req.data, "\r\n");
+ if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line);
const pf = &p.fs.panes[id];
@@ -196,11 +209,21 @@ pub fn readRun(p: *Pardes, req: Req) Reply {
.sent => return .{ .tag = req.tag, .status = .again },
.done => {},
}
- const text = slot.answer[slot.read..slot.len];
- const n = @min(text.len, req.size);
- p.fs.stage(p.gpa).appendSlice(p.gpa, text[0..n]) catch return Reply.fail(req.tag, E.NOMEM);
- slot.read += @intCast(n);
- return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } };
+ // The answer line, then the output: one stream, read from `read` on.
+ const out = p.fs.stage(p.gpa);
+ var at = slot.read;
+ for ([_][]const u8{ slot.answer[0..slot.len], slot.output }) |part| {
+ if (at >= part.len) {
+ at -= part.len;
+ continue;
+ }
+ const n = @min(part.len - at, req.size - out.items.len);
+ out.appendSlice(p.gpa, part[at..][0..n]) catch return Reply.fail(req.tag, E.NOMEM);
+ at = 0;
+ if (out.items.len == req.size) break;
+ }
+ slot.read += out.items.len;
+ return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } };
}
pub fn releaseRun(p: *Pardes, req: Req) Reply {
@@ -210,7 +233,7 @@ pub fn releaseRun(p: *Pardes, req: Req) Reply {
for (&p.fs.panes) |*pf| if (pf.run == idx) {
pf.run = null;
};
- slot.* = .{};
+ slot.reset(p.gpa, 0);
return .{ .tag = req.tag };
}
@@ -268,7 +291,33 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
- if (done.status) |status| answer(slot, "exit {d}", .{status}) else answer(slot, "exit", .{});
+ 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;
+ // Keep the tail, cut at a line start so the first line kept is whole.
+ var keep: []const u8 = printed orelse "";
+ 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(slot, "exit {d} cut", .{status})
+ else if (cut > 0)
+ answer(slot, "exit {d} cut {d}", .{ status, cut })
+ else
+ answer(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 |_| {}
+ }
pf.run = null;
}
@@ -605,7 +654,8 @@ test "run answers how its command ended once the shell is back at a prompt" {
out.put(p, prompt);
// A shell's `exec 3<>run; echo make >&3; cat <&3` reads at the offset
// its write left (5): the answer is a stream and comes whole anyway.
- try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes);
+ // The answer is how it ended, then what it printed.
+ try testing.expectEqualStrings("exit 2\nbuilding\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .off = 5, .size = 64 }).bytes);
try testing.expectEqual(@as(usize, 0), call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes.len);
_ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h });
@@ -619,7 +669,8 @@ test "run answers how its command ended once the shell is back at a prompt" {
try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 34, .op = .read, .node = node, .handle = nested.reply.handle, .size = 64 }).bytes);
_ = call(p, .{ .tag = 35, .op = .release, .node = node, .handle = nested.reply.handle });
out.put(p, "logout\r\n\x1b]133;D;0;aid=pardes\x07" ++ prompt);
- try testing.expectEqualStrings("exit 0\n", call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes);
+ const ssh = call(p, .{ .tag = 36, .op = .read, .node = node, .handle = outer.reply.handle, .size = 64 }).bytes;
+ try testing.expect(std.mem.startsWith(u8, ssh, "exit 0\n") and std.mem.indexOf(u8, ssh, "remote$ logout\n") != null);
_ = call(p, .{ .tag = 37, .op = .release, .node = node, .handle = outer.reply.handle });
// Typed text on the prompt line: busy, rather than typing into it.
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 0d328594..e8bcad2f 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -68,8 +68,6 @@ pub const e_in_use = "file in use";
/// Handles that mark an open as the one reading `event` or `pty/data`, so the
/// release knows to give the file up; other opens get `open_handle`.
const open_handle: u32 = 1;
-// ponytail: EBUSY spelled locally; use E.BUSY once cloud9 names it and pardes bumps the pin.
-const e_busy: u16 = 16;
const reader_handle: u32 = 2;
fn reads(omode: u8) bool {
@@ -527,7 +525,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// the stream; the second is refused rather than robbed.
.event => {
const reader = reads(req.omode);
- if (reader and pf.event_reader) return failText(req.tag, e_busy, e_in_use);
+ if (reader and pf.event_reader) return failText(req.tag, E.BUSY, e_in_use);
pf.readers +|= 1;
p.fs.listeners +|= 1;
pf.event_reader = pf.event_reader or reader;
@@ -535,7 +533,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
},
.pty_run => return pty.openRun(p, req, t.serial),
.pty_data => if (reads(req.omode)) {
- if (pf.pty_readers > 0) return failText(req.tag, e_busy, e_in_use);
+ if (pf.pty_readers > 0) return failText(req.tag, E.BUSY, e_in_use);
pf.pty_readers = 1;
return .{ .tag = req.tag, .handle = reader_handle };
},
diff --git a/src/panes.zig b/src/panes.zig
index 410492b8..68e03a04 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -6526,11 +6526,26 @@ pub const Terminal = struct {
/// The newest prompt continues an unfinished line (bash's PS2, marked
/// `k=c`): the shell still holds the start of the command.
continuation: bool = false,
+ /// Where the newest command's output began (its C) and ended (its
+ /// D) on the primary screen, pinned so scrolling keeps them.
+ out_start: ?*ghostty_vt.Pin = null,
+ out_end: ?*ghostty_vt.Pin = null,
+
/// The newest command to end: which one, and its status if given.
finished: ?struct { seq: u32, status: ?i32 } = null,
const Action = ghostty_vt.TerminalStream.Action;
+ /// Move one of the output pins to the cursor. Only the primary screen
+ /// is pinned: a command's own full-screen display is not its output.
+ fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void {
+ const screen = self.inner.terminal.screens.get(.primary) orelse return;
+ if (slot.*) |old| screen.pages.untrackPin(old);
+ slot.* = null;
+ if (!now or self.inner.terminal.screens.active_key != .primary) return;
+ slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null;
+ }
+
pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void {
if (comptime action == .semantic_prompt) mark: {
const aid = value.readOption(.aid) orelse break :mark;
@@ -6549,10 +6564,13 @@ pub const Terminal = struct {
.end_input_start_output => if (self.phase == .input) {
self.phase = .running;
self.started +%= 1;
+ self.pinCursor(&self.out_start, true);
+ self.pinCursor(&self.out_end, false);
},
.end_command => if (self.phase == .running) {
self.phase = .none;
self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) };
+ self.pinCursor(&self.out_end, true);
} else {
self.declined +%= 1;
},
@@ -7357,6 +7375,25 @@ pub const Terminal = struct {
state.vt.screens.active.cursor.semantic_content == .input;
}
+ /// 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;
+ // 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
+ }
+
/// At a prompt with nothing typed on its line: no input left of the
/// cursor and none under it (text typed and then Ctrl-A'd starts at the
/// cursor). Nothing further right counts: that is where fish draws a