diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 18:07:06 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 6f7beca8d27e2e00f7b90bb75015637a9aa4c950 (patch) | |
| tree | 823e143a5e29aca8049c905213e40520f3354383 /src | |
| parent | f4412d6dfdb1e2bd8549a7edba785ee242fd38f2 (diff) | |
| download | pardes-6f7beca8d27e2e00f7b90bb75015637a9aa4c950.tar.gz pardes-6f7beca8d27e2e00f7b90bb75015637a9aa4c950.zip | |
Run a line at a terminal's prompt through pty/run and read how it ended
/pane/<n>/pty/run takes factotum's rpc shape: write one line on an open,
read the answer on that same open. The answer is exit N once the command
ended and the shell is back at a prompt; busy at once when a command runs,
text is typed at the prompt, or the shell has not drawn its first prompt;
error not run when the shell refused the line without running it (fish on
a syntax error keeps it on the prompt, so it is taken back with Ctrl-U);
error shell gone when the pane closes or its shell is replaced; error no
prompt marks for a shell pardes could not instrument.
The end comes from the shell's OSC 133 marks. ghostty parses D's exit
status and drops it, so the stream now runs through a handler that wraps
ghostty's and follows prompt -> input -> running -> done. The marks pardes
injects into bash and fish carry aid=pardes and only those count, so fish
4's own marks (which doubled ours), a nested shell's, and a stray 133;D in
printed output are ignored.
Checked end to end against real bash and fish: false, exit 7, a syntax
error, sleep, busy while running, and the pane closing mid-command.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/fs-help.txt | 4 | ||||
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/host_io.zig | 14 | ||||
| -rw-r--r-- | src/ninep/events.zig | 10 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 8 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 210 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 9 | ||||
| -rw-r--r-- | src/panes.zig | 74 | ||||
| -rw-r--r-- | src/pardes.zig | 5 |
9 files changed, 314 insertions, 21 deletions
diff --git a/src/fs-help.txt b/src/fs-help.txt index db046946..cc682cf7 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -30,11 +30,11 @@ 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 printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy Pitfalls, one each: - Each open of pane/new makes another pane; two reads of one fid name the same one. - Only that open creates: ls, stat, find and every other read leave the tree alone. + Each open of pane/new makes another pane (ls, stat, find never do); a reread names the same. rmdir closes a pane even when it is dirty; index and pane/<n>/dirty show the flag. addr, dot and limit read the same pair of offsets they take, so cp between them works. dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. @@ -1238,6 +1238,7 @@ pub const Namespace = struct { node_name: [16]u8 = undefined, os_paths: std.ArrayList(OsPath) = .empty, snapshots: [tree.screen.snapshot_slots]tree.screen.Snapshot = @splat(.{}), + runs: [tree.pty.run_slots]tree.pty.Run = @splat(.{}), out: std.ArrayList(u8) = .empty, panes: [MAX_PANES]tree.pane.State = @splat(.{}), listeners: u16 = 0, diff --git a/src/host_io.zig b/src/host_io.zig index f0a8d637..fcd2225b 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -588,9 +588,9 @@ pub const Shell = struct { const bash_rc = \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" - \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]' - \\PROMPT_COMMAND='printf "\e]133;D;%s\a" "$?"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" - \\trap 'printf "\e]133;C\a"' DEBUG + \\PS1='\[\e]133;A;cl=line;aid=pardes\a\]'"$PS1"'\[\e]133;B;aid=pardes\a\]' + \\PROMPT_COMMAND='printf "\e]133;D;%s;aid=pardes\a" "$?"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}" + \\trap 'printf "\e]133;C;aid=pardes\a"' DEBUG \\ ; @@ -598,15 +598,15 @@ pub const Shell = struct { const fish_rc = \\functions -c fish_prompt __pardes_user_prompt \\function fish_prompt - \\ printf '\e]133;A;cl=line\a' + \\ printf '\e]133;A;cl=line;aid=pardes\a' \\ __pardes_user_prompt - \\ printf '\e]133;B\a' + \\ printf '\e]133;B;aid=pardes\a' \\end \\function __pardes_preexec --on-event fish_preexec - \\ printf '\e]133;C\a' + \\ printf '\e]133;C;aid=pardes\a' \\end \\function __pardes_postexec --on-event fish_postexec - \\ printf '\e]133;D;%s\a' $status + \\ printf '\e]133;D;%s;aid=pardes\a' $status \\end \\ ; diff --git a/src/ninep/events.zig b/src/ninep/events.zig index ebd1cd2c..fa0a4975 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -128,6 +128,7 @@ pub fn noteInstall(p: *Pardes, id: usize) void { /// A pane leaving; one never announced leaves silently. pub fn noteRetire(p: *Pardes, id: usize, pane: *Pane) void { if (id >= MAX_PANES) return; + tree.pty.shellGone(p, id); if (p.fs.panes[id].unannounced) { p.fs.panes[id].unannounced = false; return; @@ -168,10 +169,11 @@ fn pushLog(p: *Pardes, record: []u8) void { c.* = ' '; }; // One record larger than the ring would push every other out and then - // not fit itself; cut it to what fits instead. - const fit = @min(record.len, p.fs.log.cap - 4); - record[fit - 1] = '\n'; - p.fs.log.push(p.gpa, record[0..fit]); + // not fit itself; cut it to what fits instead, on a character boundary. + var end = @min(record.len, p.fs.log.cap - 4) - 1; + while (end > 0 and record[end] & 0xC0 == 0x80) end -= 1; + record[end] = '\n'; + p.fs.log.push(p.gpa, record[0 .. end + 1]); } /// An open freezes the ring's text, so `cat log` answers what happened lately diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 40d292c0..61073a55 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -41,6 +41,10 @@ pub const State = struct { tag_snap: std.ArrayList(u8) = .empty, pty_readers: u16 = 0, pty_out: events.Queue = .{}, + /// The pty/run open waiting on this shell's current command. + run: ?u8 = null, + /// The host started a shell it could not teach to mark its prompts. + unmarked: bool = false, /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, @@ -225,7 +229,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .pty_status => pty.status_len, .pty_ctl => pty.ctlLen(pane), .pty_data => events.pending(&pf.pty_out), - .dir, .errors, .pty => 0, + .dir, .errors, .pty, .pty_run => 0, }; } @@ -293,6 +297,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), .pty_ctl => pty.readCtl(p, req, pane), + .pty_run => pty.readRun(p, req), .dir, .errors, .pty => Reply.fail(req.tag, E.PERM), }; } @@ -386,6 +391,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply Reply.fail(req.tag, E.IO), .pty_ctl => pty.writeCtl(p, req, id), .pty_data => pty.writeData(p, req, id), + .pty_run => pty.writeRun(p, req, id, pane), .dir, .pty, .pty_status => Reply.fail(req.tag, E.PERM), }; } diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index b34bd59d..c7a32fbf 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -15,6 +15,7 @@ pub fn fileNamed(name: []const u8) ?PaneFile { if (std.mem.eql(u8, name, "ctl")) return .pty_ctl; if (std.mem.eql(u8, name, "status")) return .pty_status; if (std.mem.eql(u8, name, "data")) return .pty_data; + if (std.mem.eql(u8, name, "run")) return .pty_run; return null; } @@ -101,6 +102,147 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return tree.stagedReply(p, req); } +// ---- run: one command line in, how it ended out ---- +// +// factotum's rpc shape (factotum(4)): write a request on an open, read the +// answer on that same open, so the open is what ties a result to its +// command. The shell's own OSC 133 marks say when the command started (C) +// and ended (D, with its status); pardes injects them into bash and fish. + +pub const run_slots = 8; + +pub const Run = struct { + /// 0 is a free slot. + node: u64 = 0, + phase: enum { idle, sent, done } = .idle, + /// The shell's command count its C must reach for the end to be ours. + want: u32 = 0, + /// The shell's prompt count when the line went: a newer prompt with no + /// C for it means the shell never ran the line. + prompts: u32 = 0, + /// The refused line was taken back off the prompt. + cleared: bool = false, + answer: [48]u8 = undefined, + len: u8 = 0, +}; + +fn runOf(p: *Pardes, req: Req) ?*Run { + if (req.handle == 0 or req.handle > run_slots) return null; + const slot = &p.fs.runs[req.handle - 1]; + return if (slot.node == req.node) slot else null; +} + +pub fn openRun(p: *Pardes, req: Req, serial: u32) Reply { + _ = serial; + for (&p.fs.runs, 0..) |*slot, i| { + if (slot.node != 0) continue; + slot.* = .{ .node = req.node }; + return .{ .tag = req.tag, .handle = @intCast(i + 1) }; + } + return Reply.fail(req.tag, E.NFILE); +} + +fn answer(slot: *Run, comptime fmt: []const u8, args: anytype) void { + slot.len = @intCast((std.fmt.bufPrint(&slot.answer, fmt ++ "\n", args) catch unreachable).len); + slot.phase = .done; + pardes.turn.parked = true; // wake the read waiting on it +} + +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); + const line = std.mem.trimEnd(u8, req.data, "\r\n"); + 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]; + const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); + const marks = &state.stream.handler; + if (pf.unmarked) { + answer(slot, "error no prompt marks", .{}); + } else if (pf.run != null or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) { + // Something is running, someone has typed at the prompt, or the + // shell has not drawn its first one yet: sending now would type into + // the middle of it. + answer(slot, "busy", .{}); + } else { + slot.want = marks.started +% 1; + slot.prompts = marks.prompts; + p.emitWrite(id, line); + p.emitWrite(id, "\r"); + slot.phase = .sent; + pf.run = @intCast(slot - &p.fs.runs[0]); + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +pub fn readRun(p: *Pardes, req: Req) Reply { + const slot = runOf(p, req) orelse return Reply.fail(req.tag, E.INVAL); + switch (slot.phase) { + .idle => return .{ .tag = req.tag, .payload = .{ .staged = 0 } }, + .sent => return .{ .tag = req.tag, .status = .again }, + .done => {}, + } + const text = slot.answer[0..slot.len]; + const off = @min(req.off, text.len); + const n = @min(text.len - off, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, text[off..][0..n]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; +} + +pub fn releaseRun(p: *Pardes, req: Req) Reply { + const slot = runOf(p, req) orelse return .{ .tag = req.tag }; + const idx: u8 = @intCast(slot - &p.fs.runs[0]); + // The command runs on in the shell; nobody is waiting for it any more. + for (&p.fs.panes) |*pf| if (pf.run == idx) { + pf.run = null; + }; + slot.* = .{}; + return .{ .tag = req.tag }; +} + +/// After each chunk of a terminal's output: once the command the run sent +/// has ended and the shell is back at a prompt, the run answers how. Waiting +/// for the prompt means whoever reads the answer can send the next command +/// at once instead of finding the shell still drawing it. +pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { + if (comptime !pardes.panes.Terminal.enabled) return; + const state = pane.terminal orelse return; + const pf = &p.fs.panes[id]; + const slot = &p.fs.runs[pf.run orelse return]; + const marks = &state.stream.handler; + if (marks.phase != .input) return; + // ponytail: a prompt redrawn before the command starts (a resize in that + // instant) reads as not run; a per-command id in the marks would tell. + if (marks.started != slot.want and marks.prompts != slot.prompts) { + // fish keeps a refused line on the prompt for editing; it is the one + // this run typed into an empty prompt, so take it back (Ctrl-U), and + // answer once it is gone so the next line finds the prompt clear. + if (!pardes.panes.Terminal.promptInputEmpty(pane)) { + if (!slot.cleared) p.emitWrite(id, "\x15"); + slot.cleared = true; + return; + } + answer(slot, "error not run", .{}); + pf.run = null; + return; + } + 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", .{}); + pf.run = null; +} + +/// The pane closed or its shell was replaced: the command's end will never +/// be reported, so the run says so instead of waiting forever. +pub fn shellGone(p: *Pardes, id: usize) void { + const pf = &p.fs.panes[id]; + const idx = pf.run orelse return; + answer(&p.fs.runs[idx], "error shell gone", .{}); + pf.run = null; +} + +const e_bad_line = "bad command line"; + pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply { if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again }; const out = p.fs.stage(p.gpa); @@ -186,10 +328,11 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" { try testing.expect(th.nameAt(files, "pty").?.dir); const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); - try testing.expectEqual(@as(usize, 3), inside.len); + try testing.expectEqual(@as(usize, 4), inside.len); try testing.expectEqualStrings("ctl", inside[0].name); try testing.expectEqualStrings("status", inside[1].name); try testing.expectEqualStrings("data", inside[2].name); + try testing.expectEqualStrings("run", inside[3].name); for (inside) |d| try testing.expect(!d.dir); try testing.expectEqual(Node.of(serial, .pty_data), inside[2].node); @@ -386,6 +529,71 @@ test "pty/data writes at the shell and reads the raw stream" { try testing.expectEqualStrings("still", rd(p, data, 0, 64).bytes); } +test "run answers how its command ended once the shell is back at a prompt" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const node = Node.of(serialOf(p), .pty_run); + const out = struct { + fn put(pp: *Pardes, bytes: []const u8) void { + pp.update(.{ .output = .{ .pane = 0, .bytes = bytes } }); + while (pp.nextEffect()) |_| {} + } + }; + const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; + + // Before its first prompt the shell cannot take a line: busy, at once. + const early = call(p, .{ .tag = 1, .op = .open, .node = node }); + _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" }); + try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = early.reply.handle }); + + out.put(p, prompt); + const r = call(p, .{ .tag = 5, .op = .open, .node = node }); + const h = r.reply.handle; + const sent = call(p, .{ .tag = 6, .op = .write, .node = node, .handle = h, .data = "make\n" }); + try testing.expectEqualStrings("make\r", sent.pty()); + try testing.expectEqual(Status.again, call(p, .{ .tag = 7, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); + // A second line on the same open while the first runs is refused. + try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = node, .handle = h, .data = "ls\n" }).reply.status); + + // Untagged marks (fish's own, a nested shell's, a file being printed) + // and bash's C from its PROMPT_COMMAND trap change nothing. + out.put(p, "\x1b]133;D;9\x07\x1b]133;C;aid=pardes\x07building\r\n"); + out.put(p, "\x1b]133;C;aid=pardes\x07\x1b]133;D;2;aid=pardes\x07"); + try testing.expectEqual(Status.again, call(p, .{ .tag = 9, .op = .read, .node = node, .handle = h, .size = 64 }).reply.status); + out.put(p, prompt); + try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 10, .op = .read, .node = node, .handle = h, .size = 64 }).bytes); + // The answer stays for a re-read until the open closes. + try testing.expectEqualStrings("exit 2\n", call(p, .{ .tag = 11, .op = .read, .node = node, .handle = h, .size = 64 }).bytes); + _ = call(p, .{ .tag = 12, .op = .release, .node = node, .handle = h }); + + // Typed text on the prompt line: busy, rather than typing into it. + out.put(p, "ls -"); + const typed = call(p, .{ .tag = 13, .op = .open, .node = node }); + _ = call(p, .{ .tag = 14, .op = .write, .node = node, .handle = typed.reply.handle, .data = "true\n" }); + try testing.expectEqualStrings("busy\n", call(p, .{ .tag = 15, .op = .read, .node = node, .handle = typed.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 16, .op = .release, .node = node, .handle = typed.reply.handle }); + out.put(p, "\x08\x08\x08\x08\x1b[K"); + + // A line the shell refuses without running it (fish on a syntax error): + // a fresh prompt and no C. Waiting for a D would wait forever. + const refused = call(p, .{ .tag = 21, .op = .open, .node = node }); + _ = call(p, .{ .tag = 22, .op = .write, .node = node, .handle = refused.reply.handle, .data = "(exit 7)\n" }); + out.put(p, "\r\nfish: command substitutions not allowed\r\n" ++ prompt); + try testing.expectEqualStrings("error not run\n", call(p, .{ .tag = 23, .op = .read, .node = node, .handle = refused.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 24, .op = .release, .node = node, .handle = refused.reply.handle }); + + // The pane going away while a command runs ends the wait. + out.put(p, "\r\n" ++ prompt); + const gone = call(p, .{ .tag = 17, .op = .open, .node = node }); + _ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" }); + try p.removePane(0, null); + try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes); + _ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle }); + for (p.fs.runs) |slot| try testing.expect(slot.node == 0); +} + test "the pty queue drops the oldest at its cap" { const gpa = testing.allocator; const p = try withTerm(gpa); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index a85a1873..0d328594 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -167,6 +167,7 @@ pub const PaneFile = enum(u5) { pty_ctl, pty_status, pty_data, + pty_run, pub fn fileName(f: PaneFile) []const u8 { return switch (f) { @@ -174,6 +175,7 @@ pub const PaneFile = enum(u5) { .pty_ctl => "ctl", .pty_status => "status", .pty_data => "data", + .pty_run => "run", else => @tagName(f), }; } @@ -193,7 +195,7 @@ pub const PaneFile = enum(u5) { pub fn inPty(f: PaneFile) bool { return switch (f) { - .pty, .pty_ctl, .pty_status, .pty_data => true, + .pty, .pty_ctl, .pty_status, .pty_data, .pty_run => true, else => false, }; } @@ -531,6 +533,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { pf.event_reader = pf.event_reader or reader; if (reader) return .{ .tag = req.tag, .handle = reader_handle }; }, + .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); pf.pty_readers = 1; @@ -569,6 +572,8 @@ fn releaseHandle(p: *Pardes, req: Req) Reply { switch (target) { .top => {}, .pane => |t| { + // A run's open outlives its pane: it still holds the answer. + if (t.file == .pty_run) return pty.releaseRun(p, req); if (t.file != .event and t.file != .pty_data) return .{ .tag = req.tag }; const id = p.paneBySerial(t.serial) orelse return .{ .tag = req.tag }; const pf = &p.fs.panes[id]; @@ -633,6 +638,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { .root, .pane => Reply.fail(req.tag, E.PERM), }, .pane => |t| { + // A run's answer outlives the pane it ran in. + if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req); const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); diff --git a/src/panes.zig b/src/panes.zig index 156d443a..3b1bb1b4 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -6502,7 +6502,59 @@ pub const Terminal = struct { pub const history_max = if (enabled) 64 else 8; pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; - pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; + pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void; + + /// The `aid` pardes's injected bash and fish marks carry (host_io.zig). + pub const prompt_aid = "pardes"; + + /// ghostty's handler, plus where the shell is in its command cycle as + /// its OSC 133 marks tell it: ghostty parses D's exit status and drops + /// it, and /pane/<n>/pty/run waits on exactly that. Only marks carrying + /// pardes's own `aid` move it, so fish's native marks, a nested shell's + /// and a stray `133;D` in some file being printed all pass it by. + pub const MarkedHandler = if (enabled) struct { + inner: ghostty_vt.TerminalStream.Handler, + phase: enum { none, prompt, input, running } = .none, + /// Counts commands started (C at the input phase). + started: u32 = 0, + /// Counts prompts drawn (B), so a line the shell refused without + /// running (a fish syntax error: no C, no D, a new prompt) shows. + prompts: u32 = 0, + /// 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; + + 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; + if (!std.mem.eql(u8, aid, prompt_aid)) break :mark; + switch (value.action) { + .fresh_line_new_prompt => self.phase = .prompt, + .end_prompt_start_input => { + self.phase = .input; + self.prompts +%= 1; + }, + // bash's DEBUG trap also fires on its own PROMPT_COMMAND, + // outside the input phase; only a C that ends input counts + .end_input_start_output => if (self.phase == .input) { + self.phase = .running; + self.started +%= 1; + }, + .end_command => if (self.phase == .running) { + self.phase = .none; + self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) }; + }, + else => {}, + } + } + self.inner.vt(action, value); + } + + pub fn deinit(self: *@This()) void { + self.inner.deinit(); + } + } else void; pub const Replay = struct { bytes: [1024 * 1024]u8 = undefined, @@ -6784,12 +6836,12 @@ pub const Terminal = struct { .rows = rows, .max_scrollback = 16 * 1024 * 1024, }); - state.stream = state.vt.vtStream(); + state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); state.replay.head = 0; state.replay.len = 0; state.reply_len = 0; - state.stream.handler.effects.write_pty = ptyReport; - state.stream.handler.effects.device_attributes = ptyDeviceAttrs; + state.stream.handler.inner.effects.write_pty = ptyReport; + state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; pane.terminal = state; pane.tty_filter = true; return pane; @@ -6938,7 +6990,7 @@ pub const Terminal = struct { while (p.nextEffect()) |_| {} const pane = p.panes[0].?; const state = pane.terminal.?; - try std.testing.expect(state.stream.handler.terminal == &state.vt); + try std.testing.expect(state.stream.handler.inner.terminal == &state.vt); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } }); while (p.nextEffect()) |effect| { if (effect == .write) return error.PrematureTerminalReply; @@ -7294,6 +7346,18 @@ pub const Terminal = struct { state.vt.screens.active.cursor.semantic_content == .input; } + /// At a prompt with nothing typed on its line, wherever the cursor is + /// (someone may have typed and pressed Ctrl-A). fish suggests nothing on + /// an empty line, so its autosuggestions never make this false. + pub fn promptInputEmpty(pane: *const Pane) bool { + if (comptime !enabled) return false; + if (!promptInputReady(pane)) return false; + const cursor = &pane.terminal.?.vt.screens.active.cursor; + for (cursor.page_pin.cells(.all)) |cell| + if (cell.semantic_content == .input and !cell.isEmpty()) return false; + return true; + } + /// Encode one key for the program that owns a raw terminal and queue its pty /// write, respecting the application's keyboard protocol and terminal modes. pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { diff --git a/src/pardes.zig b/src/pardes.zig index 06bc81ed..29a9ab3a 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -7024,6 +7024,10 @@ pub const Pardes = struct { } pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void { + if (id < MAX_PANES) { + ctlfs.pty.shellGone(p, id); // a respawn replaced whatever ran + p.fs.panes[id].unmarked = !prompt_marks; + } if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; @@ -7848,6 +7852,7 @@ pub const Pardes = struct { const pane = p.panes[o.pane] orelse return; ctlfs.events.notePtyOutput(p, o.pane, o.bytes); panes.Terminal.feedOutput(p, pane, o.bytes); + ctlfs.pty.noteMarks(p, o.pane, pane); }, .eof => |e| p.removePane(e.pane, null) catch |err| { if (p.panes[e.pane]) |pane| pane.mode = .normal; |
