summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md12
-rw-r--r--docs/fs.md27
-rw-r--r--docs/open-questions.md17
-rw-r--r--src/builtins.zig15
-rw-r--r--src/config.zig1
-rw-r--r--src/detached/server.zig5
-rw-r--r--src/exec.zig122
-rw-r--r--src/fs-help.txt2
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig64
-rw-r--r--src/macos.zig8
-rw-r--r--src/ninep/ctl.zig8
-rw-r--r--src/panes.zig7
-rw-r--r--src/pardes.zig200
-rw-r--r--src/tagline.zig10
-rw-r--r--src/tty/tty.zig6
-rw-r--r--test/snapshots/chordcut.golden14
-rw-r--r--test/snapshots/cmdword.golden31
-rw-r--r--test/snapshots/cmdword.snap22
-rw-r--r--test/snapshots/layout-open.golden6
-rw-r--r--test/snapshots/layout-open.snap8
-rw-r--r--test/snapshots/ttytaken.golden91
-rw-r--r--test/snapshots/ttytaken.snap59
23 files changed, 496 insertions, 247 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 63dec104..df53c90f 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -60,7 +60,7 @@ $m/focus the serial of the pane with the keyboard (empty while a column/wor
echo a serial into it to move the keyboard (a folded pane stays folded)
$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin
(Newcol, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the
- commands pardes typed into terminals; Exit and Restore refuse once per
+ commands pardes started (command panes, lines it typed into shells); Exit and Restore refuse once per
unsaved pane, `<name>: Modified (Exit again to discard)`, and the same
word again DISCARDS that text -- not a retry, unlike lock's `file in use`;
Kill signals only the foreground job, so of `sleep 30; echo done` the
@@ -117,9 +117,13 @@ active pane from the root and at that pane from `$m/pane/<n>/look` and
made, or the pane it focused or acted on. A command that fails is reported in
the editor, not as a write error, so inspect the resulting pane, index, message
or screen; only a malformed line fails the write itself. A word no builtin
-knows (a typo included) is typed into a terminal -- the pane itself if it is a
-terminal at its prompt, else one in the pane's directory, made if need be --
-and is not reported as an error. Every refused 9P write adds an `err <serial|->
+knows (a typo included) is a command line: written at a terminal at its
+prompt it is typed into that shell; from anywhere else it runs as a command
+pane, a terminal whose child is `$SHELL -c` the line in the pane's
+directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run
+<serial> <word>` and `exit <serial> <N|?>` -- `exec` reads back its serial,
+so follow `log` for the exit. The directory's next command reuses a finished
+command pane, below what it showed. Every refused 9P write adds an `err <serial|->
<file>: <why>` record to `$m/log`; through a mount the write itself only says
`Invalid argument`.
diff --git a/docs/fs.md b/docs/fs.md
index f98a1ae0..3efc5b36 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -123,9 +123,10 @@ and opens into the new editor, which acme, whose Load only adds windows,
never needed); and `Kill`, which
does not quit but stops commands, as acme's does: bare, every command pardes
started, and `Kill make ls`, those whose line begins with one of the words. A
-command pardes started is a line it typed into a terminal (a word written to
-`exec`, a middle click on one, a `pty/run`), from its shell's start mark (C)
-to its end mark (D); Kill sends its foreground job SIGTERM -- acme posts the
+command pardes started is a command pane's, until its child exits -- Kill
+sends its whole process group SIGTERM -- or a line it typed into a terminal (a
+word written to `exec`, a middle click on one, a `pty/run`), from its shell's
+start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the
"kill" note, which ends a process -- and never signals the shell itself;
in a shell running without job control (`set +m`) the job shares the
shell's group, so there is none to signal: Kill says `Kill: no job to
@@ -232,13 +233,19 @@ same tree without leaving the process.
- a line written to `exec` is a middle click: a command word from
`src/builtins.zig` (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`,
`Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...),
- or anything else, which is typed into a terminal: the pane itself when it
- is a terminal at its prompt, otherwise a terminal in the pane's directory
- that is at its prompt, and failing both a new one made below the last
- column. That is not an error, whatever the shell makes of the line, and a
- misspelled builtin word ends up there too (whether it should is an open
- question, docs/open-questions.md). `echo Tty > pane/<n>/ctl` makes a
- terminal in that pane's directory outright.
+ or anything else, a command line. Written at a terminal at its prompt it
+ is typed into that shell. From anywhere else -- a file, a scratch, a tag,
+ a terminal whose tty a program holds -- it runs as a command pane: a
+ terminal whose child is `$SHELL -c` the line in the pane's directory,
+ which shows its output and then `exit N` (its tag reads `<dir> (<line>)
+ running`, then `exit N`), and stays. The directory's next command runs in
+ that pane once it is done, below what it showed, after a `% <line>` line;
+ one still running gets a second pane. A command pane's own exec starts
+ the next command there too. The log says `run <serial> <word>` and `exit
+ <serial> <N|?>`; `exec` reads back the command pane's serial; Kill ends
+ its whole process group; a line is at most 1 KB. A misspelled word is a
+ command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes
+ an interactive terminal in that pane's directory.
The root's pair clicks at the active pane and `/pane/<n>/look` and
`/pane/<n>/exec` at that pane. Blank lines are skipped, and every other line
diff --git a/docs/open-questions.md b/docs/open-questions.md
index db491213..a2ce2086 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -6,7 +6,22 @@ picked up without redoing the research.
## Where does an unknown command word run?
Raised 2026-09-27 in the review of pardes against acme (Plan 9 source at
-`~/05-genizah/principia-softwarica`). Undecided.
+`~/05-genizah/principia-softwarica`).
+
+**Decided 2026-09-28: a command pane.** Clicked or written at an interactive
+terminal at its prompt, the line is typed into that shell, whose state the
+clicker can see. From anywhere else it runs as its own terminal pane whose
+child is `$SHELL -c` the line in the pane's directory: full emulation
+(colours, `less`, `vim`, `sudo`'s prompt work), the exit status from waiting
+on the child (`exit N`, `exit 127` for a misspelling, no prompt marks
+needed), Kill signalling its process group, `run`/`exit` records in the log,
+and `exec` answering its serial. One command pane per directory is reused
+once done, and keeps what it showed, as acme appends to `+Errors` and never
+clears it (util.c:213-221). Chosen over acme's process-into-`+Errors` because
+it keeps interactive programs working without a streaming runner, and over
+a PATH check before typing into a shell, which misjudges builtins, aliases
+and functions and leaves the shell's state in the way. The analysis below is
+what it was decided from.
**Today.** A middle-click, an `exec` write or a tag word that is not a builtin
is typed into a terminal pane: the pane itself when it takes a command line,
diff --git a/src/builtins.zig b/src/builtins.zig
index d06897ff..19d16e1e 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -419,6 +419,21 @@ pub const Kill = struct {
var jobless = false;
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
+ // A command pane's child is the command: the whole of its
+ // process group goes, as acme's Kill ends the process.
+ if (pane.command) |line| if (!pane.command_done) {
+ const said = std.mem.trim(u8, line, " \t");
+ const first = said[0 .. std.mem.indexOfAny(u8, said, " \t") orelse said.len];
+ if (names.len > 0) {
+ var words = std.mem.tokenizeAny(u8, names, " \t");
+ while (words.next()) |w| {
+ if (std.mem.eql(u8, w, first)) break;
+ } else continue;
+ }
+ c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = .term } });
+ stopped = true;
+ continue;
+ };
if (!panes.Terminal.commandRunning(pane)) continue;
const sent = pane.sent_command.?;
if (names.len > 0) {
diff --git a/src/config.zig b/src/config.zig
index 6d0cf0a6..1a505334 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -248,6 +248,7 @@ pub const pane_builtins_str = "Tty Collapse Del";
pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del";
pub const file_pane_builtins_str = "Save Tty Collapse Del";
pub const terminal_pane_builtins_str = "Tty Save Mode Filter Collapse Del";
+pub const command_pane_builtins_str = "Kill Save Collapse Del";
// Each mode badge is one codepoint.
pub const tag_normal = " ";
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 61fbe111..86ba2206 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -679,9 +679,12 @@ pub const Session = struct {
}
fn paneEof(s: *Session, pane: u8) void {
+ // Reaped here, its status kept, it is no shell to retire.
+ const status = host_io.exitStatus(s.ptys[pane].pid, 100);
+ if (status != null) s.ptys[pane].pid = 0;
s.closePty(pane);
s.harvest();
- s.core.update(.{ .eof = .{ .pane = pane } });
+ s.core.update(.{ .eof = .{ .pane = pane, .status = status } });
}
fn harvest(s: *Session) void {
diff --git a/src/exec.zig b/src/exec.zig
index 2faf84de..07d5e92f 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -30,7 +30,8 @@ const Pardes = pardes.Pardes;
pub fn takesCommandLine(p: *const Pardes, id: usize) bool {
const pane = p.panes[id] orelse return false;
- if (!pane.isTerminal()) return false;
+ // A command pane's child is its command, never a shell to type at.
+ if (!pane.isTerminal() or pane.command != null) return false;
// A mounted shell sits behind sudo's process supervisor. Its OSC 133
// prompt marks identify input readiness; the launcher's executable is
// not the shell executable used by the ordinary process heuristic.
@@ -368,26 +369,16 @@ pub fn replaceStillborn(p: *Pardes) void {
if (was_active) p.active = new_id;
}
-fn ttyForDir(p: *Pardes, dir: []const u8) ?usize {
- for (p.panes, 0..) |slot, i| if (slot) |tt| {
- if (!std.mem.eql(u8, tt.cwdSlice(), dir)) continue;
- if (takesCommandLine(p, i)) return i;
- };
- const free = p.freeSlot() orelse return null;
- const nt = p.newShell(free, dir) catch return null;
- nt.greet = false;
- const rc = if (p.ncol > 0) p.ncol - 1 else 0;
- if (p.col_n[rc] > 0) {
- const src = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]);
- const f = layout.findPane(p, src).?;
- layout.splitBelow(p, src, nt);
- layout.insert(p, f.col, f.idx + 1, free);
- } else layout.insert(p, rc, p.col_n[rc], free);
- return free;
-}
+/// The longest line a command pane runs.
+pub const command_max = 1024;
const max_exec_depth = 8;
+/// A word no builtin knows (acme's exec.c run()). Clicked in an interactive
+/// terminal, it is typed into that shell, whose state the click can see;
+/// from anywhere else it runs as its own command pane, a terminal whose
+/// child is `$SHELL -c` the line, which shows its output and `exit N` when
+/// done, and which Kill signals. Answers the pane it went to.
pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
const pane = p.panes[id] orelse return null;
const cmd = commandText(txt);
@@ -397,16 +388,93 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize {
if (p.exec_depth >= max_exec_depth) return null;
p.exec_depth += 1;
defer p.exec_depth -= 1;
- const dst = (if (takesCommandLine(p, id)) id else ttyForDir(p, Pardes.paneDir(pane))) orelse return null;
- panes.Terminal.padOutputBelowEdits(p, dst);
- panes.Terminal.noteCommand(p.panes[dst].?, cmd);
- if (panes.Terminal.queuePendingCommand(p.panes[dst].?, cmd) catch |err| {
+ if (!takesCommandLine(p, id)) return runCommand(p, id, cmd);
+ panes.Terminal.padOutputBelowEdits(p, id);
+ panes.Terminal.noteCommand(pane, cmd);
+ if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| {
p.reportError(id, "queue command", err);
- return dst;
- }) return dst;
- p.emitWrite(dst, cmd);
- p.emitWrite(dst, "\r");
- return dst;
+ return id;
+ }) return id;
+ p.emitWrite(id, cmd);
+ p.emitWrite(id, "\r");
+ return id;
+}
+
+/// Runs `line` in a command pane for `from`'s directory: `from` itself if
+/// it is one whose command is done, else the directory's command pane that
+/// is done, else a new one below the last pane of the rightmost column.
+/// A pane reused keeps what it showed: the new command runs below a line
+/// naming it, as acme appends to +Errors and never clears it (util.c:213).
+fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
+ if (line.len > command_max) {
+ p.reportFailure(from, "exec: a command line is at most 1024 bytes");
+ return null;
+ }
+ const src = p.panes[from] orelse return null;
+ const dir = Pardes.paneDir(src);
+ const reuse: ?usize = if (src.command != null and src.command_done) from else for (p.panes, 0..) |slot, i| {
+ const other = slot orelse continue;
+ if (other.command != null and other.command_done and std.mem.eql(u8, other.cwdSlice(), dir)) break i;
+ } else null;
+ if (reuse) |id| {
+ const pane = p.panes[id].?;
+ const owned = p.gpa.dupe(u8, line) catch |err| {
+ p.reportError(from, "exec", err);
+ return null;
+ };
+ p.gpa.free(pane.command.?);
+ pane.command = owned;
+ pane.command_done = false;
+ pane.command_status = null;
+ pane.body.mode = .tty;
+ var buf: [command_max + 8]u8 = undefined;
+ const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else "";
+ panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&buf, "{s}% {s}\r\n", .{ lead, line }) catch "%\r\n");
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ noteRun(p, pane, "run", line);
+ return id;
+ }
+ const free = p.freeSlot() orelse {
+ p.reportFailure(from, "exec: no pane free for the command");
+ return null;
+ };
+ const pane = p.newCommand(free, dir, line) catch |err| {
+ p.reportError(from, "exec", err);
+ return null;
+ };
+ const rc = if (p.ncol > 0) p.ncol - 1 else 0;
+ if (p.col_n[rc] > 0) {
+ const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]);
+ const f = layout.findPane(p, parent).?;
+ layout.splitBelow(p, parent, pane);
+ layout.insert(p, f.col, f.idx + 1, free);
+ } else layout.insert(p, rc, p.col_n[rc], free);
+ noteRun(p, pane, "run", line);
+ return free;
+}
+
+/// `run <serial> <word>` or `exit <serial> <N|?>` in the log.
+fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
+ var buf: [96]u8 = undefined;
+ const word = std.mem.trim(u8, said, " \t");
+ const first = word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len];
+ pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, first[0..@min(first.len, 64)] }) catch return);
+}
+
+/// A command pane's child is gone: the pane stays, showing what it said
+/// and how it ended, and is the next command's for its directory.
+pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void {
+ const pane = p.panes[id] orelse return;
+ pane.command_done = true;
+ pane.command_status = status;
+ pane.body.mode = .normal;
+ var buf: [16]u8 = undefined;
+ const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?";
+ var said: [32]u8 = undefined;
+ const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else "";
+ panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n");
+ noteRun(p, pane, "exit", code);
+ p.needs_frame = true;
}
/// Trim and unwrap clickable command-word notation once for every command
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 7e869f9e..0e87f40e 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -39,7 +39,7 @@ Pitfalls, one each:
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; writes append; truncating clears it, default words too.
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
- exec: a word no builtin knows is typed into a terminal in the pane's directory, and not an error.
+ exec: a non-builtin line runs as a command pane ($SHELL -c; exit N shown, logged), a shell's typed in.
Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
lock/unlock on a pane's ctl need a held fd (exec 3>ctl); a lock someone holds fails: retry.
Closing a column's last pane leaves a +New in its place; Delcol closes the column.
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 9ada305b..bfa83175 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -3878,13 +3878,19 @@ const Shell = struct {
},
.eof => |e| {
if (s.gens[e.pane] == e.gen) {
+ // Reaped here, its status kept, it is no shell to retire.
+ const status = if (s.ptys[e.pane]) |*pt| status: {
+ const got = host_io.exitStatus(pt.pid, 100);
+ if (got != null) pt.pid = 0;
+ break :status got;
+ } else null;
s.closePty(e.pane);
if (e.failure) |err| {
if (s.core.panes[e.pane]) |pane| {
pane.body.mode = .normal;
s.core.reportError(e.pane, "terminal reader", err);
}
- } else s.core.update(.{ .eof = .{ .pane = e.pane } });
+ } else s.core.update(.{ .eof = .{ .pane = e.pane, .status = status } });
}
s.saw_event = true;
},
diff --git a/src/host_io.zig b/src/host_io.zig
index 6009c00f..820cb2b6 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -949,6 +949,9 @@ test "the child environment replaces the launcher's terminal identity exactly on
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
+/// The longest line a command pane runs (exec.zig refuses a longer one).
+pub const exec_command_max = pardes.exec.command_max;
+
pub fn forkShell(
core: ?*pardes.Pardes,
pane: usize,
@@ -973,9 +976,19 @@ pub fn forkShell(
};
var master: c_int = -1;
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = Shell.resolve(bin, &path_buf, prompt_rcs);
+ var spawn = Shell.resolve(bin, &path_buf, prompt_rcs);
+ // A command pane's child is the shell running its one line, which has
+ // no prompt to mark (exec.zig runCommand).
+ const one_line = if (core) |c| if (c.panes[pane]) |pn| pn.command else null else null;
+ var command_buf: [exec_command_max + 1]u8 = undefined;
+ if (one_line) |line| {
+ if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand;
+ @memcpy(command_buf[0..line.len], line);
+ command_buf[line.len] = 0;
+ spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null };
+ }
var helper_buf: [4096]u8 = undefined;
- const mounted = if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
+ const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
const helper: ?[:0]u8 = if (mounted) blk: {
if (comptime builtin.os.tag != .linux) return error.LinuxRequired;
if (fs == null) return error.SessionSocketRequired;
@@ -1016,7 +1029,7 @@ pub fn forkShell(
_exit(127);
}
ninep_io.setCloexec(master);
- if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
+ if (core) |c| c.acknowledgeShell(pane, std.mem.span(spawn.path), one_line == null and spawn.argv[1] != null);
return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
}
@@ -1349,6 +1362,29 @@ pub fn retireShell(pid: libc.pid_t) void {
thread.detach();
}
+/// A pane's child at its pty's end of file: reaped, and how it exited
+/// (its code, or 128 and the signal that ended it), if it is gone within
+/// `wait_ms`; null if it is still running (a job it left holds nothing
+/// open, but the child itself may be a moment behind its pty), which the
+/// caller then retires as it would any shell.
+pub fn exitStatus(pid: libc.pid_t, wait_ms: u32) ?u8 {
+ if (pid <= 0) return null;
+ var waited: u32 = 0;
+ while (true) : (waited += 2) {
+ var status: c_int = 0;
+ const got = libc.waitpid(pid, &status, libc.W.NOHANG);
+ if (got == pid) {
+ const s: u32 = @bitCast(status);
+ if (libc.W.IFEXITED(s)) return @truncate(libc.W.EXITSTATUS(s));
+ if (libc.W.IFSIGNALED(s)) return @truncate(128 + @as(u32, @intFromEnum(libc.W.TERMSIG(s))));
+ return null;
+ }
+ if (got < 0 or waited >= wait_ms) return null;
+ const ts: libc.timespec = .{ .sec = 0, .nsec = 2 * std.time.ns_per_ms };
+ _ = libc.nanosleep(&ts, null);
+ }
+}
+
fn reapShell(pid: libc.pid_t, grace_ms: u32) void {
var waited: u32 = 0;
while (waited < grace_ms) : (waited += 5) {
@@ -1711,6 +1747,28 @@ test "Kill stops the foreground job, never the shell, and finds none with job co
try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG));
}
+test "a command pane's child is its shell running the one line, and its exit status is read at the end" {
+ if (comptime !tty_probe_platform) return error.SkipZigTest;
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const id = core.freeSlot().?;
+ _ = try core.newCommand(id, "", "printf 'said '; exit 7");
+ while (core.nextEffect()) |_| {}
+ const rcs: Shell.PromptFiles = .{};
+ const child = try forkShell(core, id, &rcs, "/bin/sh", "", 24, 80, null);
+ defer _ = libc.close(child.file.handle);
+ var got: [256]u8 = undefined;
+ var n: usize = 0;
+ while (n < got.len) {
+ const r = libc.read(child.file.handle, got[n..].ptr, got.len - n);
+ if (r <= 0) break; // EIO once the child is gone: the pty's end
+ n += @intCast(r);
+ }
+ try std.testing.expect(std.mem.indexOf(u8, got[0..n], "said") != null);
+ try std.testing.expectEqual(@as(?u8, 7), exitStatus(child.pid, 2000));
+}
+
test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" {
const pid = libc.fork();
if (pid == 0) {
diff --git a/src/macos.zig b/src/macos.zig
index 7bfa6c49..97dab9a8 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1280,8 +1280,14 @@ fn drainInbox(st: *State) bool {
},
.eof => |e| {
if (st.gens[e.pane] != e.gen) continue;
+ // Reaped here, its status kept, it is no shell to retire.
+ const status = if (st.ptys[e.pane]) |*pt| status: {
+ const got = host_io.exitStatus(pt.pid, 100);
+ if (got != null) pt.pid = 0;
+ break :status got;
+ } else null;
reap(st, e.pane);
- st.core.update(.{ .eof = .{ .pane = e.pane } });
+ st.core.update(.{ .eof = .{ .pane = e.pane, .status = status } });
},
.lsp_done => |d| {
st.core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } });
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c1cfa1f6..deb64565 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -67,7 +67,12 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
return noteResult(p, context);
}
const before = Serials.snapshot(p);
- if (exec) _ = pardes.exec.execute(p, id, text) else look.lookAt(p, id, text);
+ // An exec answers the pane it went to: a command pane it reused, or
+ // the terminal it typed into.
+ const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
+ look.lookAt(p, id, text);
+ break :blk null;
+ };
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
@@ -75,6 +80,7 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
created = true;
};
if (created) return;
+ if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
diff --git a/src/panes.zig b/src/panes.zig
index 09b93aa3..a7f7f69f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -102,6 +102,13 @@ pub const Pane = struct {
/// middle click, a pty/run): its first word, which Kill matches, and
/// the shell's command count its start (C mark) reaches.
sent_command: ?struct { word: [64]u8 = undefined, len: u8 = 0, seq: u32 = 0 } = null,
+ /// A command pane (exec.zig runCommand): the line its child runs as
+ /// `$SHELL -c`, owned, where an interactive terminal has null.
+ command: ?[]u8 = null,
+ /// The command's child has exited, and with what status (null: unknown,
+ /// shown `exit ?`).
+ command_done: bool = false,
+ command_status: ?u8 = null,
/// The revision an Exit or a Restore last warned this pane was modified
/// at, and which of them: only that word goes ahead on the warning
/// (builtins.zig warnModified).
diff --git a/src/pardes.zig b/src/pardes.zig
index 9fa747cd..1bb13e3d 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1282,34 +1282,35 @@ test "an unsaved file marker sits between its path and builtins until Save" {
try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null);
}
-test "unknown Exec from an image writes to a terminal in the image directory" {
+test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
defer p.deinit();
while (p.nextEffect()) |_| {}
p.acknowledgeShell(0, "/bin/sh", false);
+ // A terminal free at a prompt in the same directory is not borrowed.
p.setCwd(0, "/tmp/pardes-image-dir");
const image_doc = try p.newDocPane(1);
image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
try std.testing.expectEqualStrings("/tmp/pardes-image-dir", Pardes.paneDir(image_doc));
- try std.testing.expectEqual(@as(?usize, 0), exec.execute(p, 1, "echo image-fallback"));
+ const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun;
+ try std.testing.expect(dst != 0 and dst != 1);
+ try std.testing.expectEqualStrings("echo image-fallback", p.panes[dst].?.command.?);
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
+ var spawned = false;
while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, 0), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
+ .write => return error.TypedIntoATerminal,
+ .spawn => |sp| if (sp.pane == dst) {
+ try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
+ spawned = true;
},
- .spawn => return error.ImageExecSpawnedDespiteMatchingTerminal,
else => {},
};
- try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
+ try std.testing.expect(spawned);
}
+
const FakeTtyQuery = struct {
taken: [MAX_PANES]bool = @splat(false),
asked: usize = 0,
@@ -1327,7 +1328,7 @@ const FakeTtyQuery = struct {
}
};
-test "Exec in a terminal whose tty is taken spawns a shell instead of typing at the program" {
+test "Exec in a terminal whose tty is taken runs as a command pane instead of typing at the program" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{});
defer p.deinit();
@@ -1340,42 +1341,85 @@ test "Exec in a terminal whose tty is taken spawns a shell instead of typing at
host.install(p);
host.taken[0] = true;
const dst = exec.execute(p, 0, "echo taken-fallback") orelse return error.ExecFoundNowhereToRun;
- // somewhere ELSE — and still without moving focus, which is execute's
+ // somewhere ELSE -- and still without moving focus, which is execute's
// contract and the whole difference between it and a look
try std.testing.expect(dst != 0);
try std.testing.expectEqual(@as(usize, 0), p.active);
-
- var spawned: ?Effect = null;
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
+ try std.testing.expectEqualStrings("echo taken-fallback", p.panes[dst].?.command.?);
+ var spawned = false;
while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
+ .write => return error.WroteACommandLineIntoATerminal,
+ .spawn => |sp| if (sp.pane == dst) {
+ // ...in the directory the command was about, the taken pane's own
+ try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
+ spawned = true;
},
- .spawn => spawned = effect,
else => {},
};
- const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), sp.pane);
- // ...in the directory the command was about, which is the taken pane's own
- try std.testing.expectEqualStrings("/tmp/pardes-taken", sp.cwd.slice());
- try std.testing.expectEqual(@as(usize, 0), sent_len);
- p.acknowledgeShell(dst, "/bin/sh", false);
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- else => {},
+ try std.testing.expect(spawned);
+}
+
+test "a command pane shows how its command ended, and the next command there runs below it" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{});
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.setCwd(0, "/tmp/pardes-cmd");
+ const doc = try p.newDocPane(1);
+ doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-cmd/pic.ppm") };
+
+ const dst = exec.execute(p, 1, "make -j8") orelse return error.NoCommandPane;
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[dst].?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) running"));
+ // Running, a second command goes to a pane of its own.
+ const other = exec.execute(p, 1, "ls") orelse return error.NoCommandPane;
+ try std.testing.expect(other != dst);
+ while (p.nextEffect()) |_| {}
+ p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "compiled\r\n" } });
+ p.update(.{ .eof = .{ .pane = @intCast(dst), .status = 2 } });
+ try std.testing.expect(p.panes[dst] == pane);
+ try std.testing.expect(pane.command_done);
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make -j8) exit 2"));
+ // Kill leaves a finished one be.
+ try std.testing.expect(p.executeBuiltinLine(0, "Kill make"));
+ while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) return error.SignalledAFinishedCommand;
+ // Done, the directory's next command runs there, below what it showed.
+ try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test"));
+ var respawned = false;
+ while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) {
+ respawned = true;
+ };
+ try std.testing.expect(respawned);
+ const body = try panes.Terminal.screenTextAlloc(pane, gpa);
+ defer gpa.free(body);
+ const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost;
+ const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost;
+ const next = std.mem.indexOf(u8, body, "% make test") orelse return error.NoSeparator;
+ try std.testing.expect(said < ended and ended < next);
+ try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, pane), "(make test) running"));
+ // Kill stops a running one: its whole process group, SIGTERM.
+ try std.testing.expect(p.executeBuiltinLine(0, "Kill make"));
+ var signalled = false;
+ while (p.nextEffect()) |effect| if (effect == .signal_pty and effect.signal_pty.pane == dst) {
+ try std.testing.expectEqual(PtySignal.term, effect.signal_pty.sig);
+ signalled = true;
};
- try std.testing.expectEqualStrings("echo taken-fallback\r", sent[0..sent_len]);
+ try std.testing.expect(signalled);
+ // The log says what ran and how it ended.
+ const log = try ctlfs.events.freezeLog(p);
+ defer gpa.free(log.bytes);
+ var want: [64]u8 = undefined;
+ try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "run {d} make\n", .{pane.serial})) != null);
+ try std.testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "exit {d} 2\n", .{pane.serial})) != null);
+ // A command line longer than a command pane takes is refused.
+ const long = "x" ** (exec.command_max + 1);
+ try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 1, long));
}
+
test "image dump restores source bytes renderer choices and exact custom tail" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -1428,50 +1472,6 @@ test "image dump restores source bytes renderer choices and exact custom tail" {
try std.testing.expectEqualStrings(" Keep Del", redump.panes[0].tag_tail.?);
}
-test "Exec from a document pane skips an occupied terminal in its directory and spawns" {
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{});
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- p.setCwd(0, "/tmp/pardes-image-dir");
- const image_doc = try p.newDocPane(1);
- image_doc.image = .{ .path = try gpa.dupe(u8, "/tmp/pardes-image-dir/pic.ppm") };
- var host: FakeTtyQuery = .{};
- host.install(p);
- host.taken[0] = true;
-
- const dst = exec.execute(p, 1, "echo image-fallback") orelse return error.ExecFoundNowhereToRun;
- try std.testing.expect(dst != 0 and dst != 1);
-
- var spawned: ?Effect = null;
- var sent: [256]u8 = undefined;
- var sent_len: usize = 0;
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- if (w.pane == 0) return error.WroteACommandLineIntoTheProgramOnTheTty;
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- try std.testing.expect(w.bytes.slice().len <= sent.len - sent_len);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- .spawn => spawned = effect,
- else => {},
- };
- const sp = (spawned orelse return error.NoShellForTheOccupiedTerminal).spawn;
- try std.testing.expectEqualStrings("/tmp/pardes-image-dir", sp.cwd.slice());
- try std.testing.expectEqual(@as(usize, 0), sent_len);
- p.acknowledgeShell(dst, "/bin/sh", false);
- while (p.nextEffect()) |effect| switch (effect) {
- .write => |w| {
- try std.testing.expectEqual(@as(u8, @intCast(dst)), w.pane);
- @memcpy(sent[sent_len..][0..w.bytes.slice().len], w.bytes.slice());
- sent_len += w.bytes.slice().len;
- },
- else => {},
- };
- try std.testing.expectEqualStrings("echo image-fallback\r", sent[0..sent_len]);
-}
test "a Look on a directory does not type ls into an occupied terminal" {
const gpa = std.testing.allocator;
@@ -1574,11 +1574,13 @@ test "the host is asked about a tty only where a command line is about to go" {
while (p.nextEffect()) |_| {}
try std.testing.expectEqual(@as(usize, 1), host.asked);
+ // ...and a taken one is asked once too: the line then runs as a command
+ // pane, with no other terminal to ask about.
host.taken[0] = true;
host.asked = 0;
_ = exec.execute(p, 0, "echo lazy-again");
while (p.nextEffect()) |_| {}
- try std.testing.expectEqual(@as(usize, 2), host.asked);
+ try std.testing.expectEqual(@as(usize, 1), host.asked);
}
test "New opens an empty scratch below the caller, inheriting its directory" {
@@ -3081,7 +3083,9 @@ pub const Event = union(enum) {
row_metrics: ?RowMetrics = null,
},
output: struct { pane: u8, bytes: []const u8 },
- eof: struct { pane: u8 },
+ /// The pane's child hung up its pty; `status` is how it exited, when
+ /// the host could reap it at once (a command pane shows it).
+ eof: struct { pane: u8, status: ?u8 = null },
lsp_resp: struct { id: u32, rows: ?[]const u8 },
pipe_resp: struct {
id: u32,
@@ -4183,6 +4187,7 @@ pub const Pardes = struct {
p.shell_rows.dropPane(pane);
for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot);
panes.Terminal.deinitPendingCommand(pane);
+ if (pane.command) |line| p.gpa.free(line);
if (pane.image) |*iv| {
iv.deinit(p.image_gpa);
}
@@ -4228,6 +4233,25 @@ pub const Pardes = struct {
return pane;
}
+ /// A command pane: a terminal whose child is `$SHELL -c line` rather
+ /// than an interactive shell, left showing its output and `exit N` when
+ /// it is done (exec.zig runCommand).
+ pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ if (cwd.len > effect_path_cap) return error.PathTooLong;
+ const owned = try p.gpa.dupe(u8, line);
+ errdefer p.gpa.free(owned);
+ const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
+ pane.command = owned;
+ pane.body.mode = .tty;
+ // Its directory is the one it was run for, and says which of the
+ // directory's commands reuse it; the child's own cd does not move it.
+ pane.setOwnedCwd(cwd) catch {};
+ p.installPane(id, pane);
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ return pane;
+ }
+
pub fn newDocPane(p: *Pardes, id: usize) !*Pane {
std.debug.assert(p.panes[id] == null);
const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h);
@@ -4324,6 +4348,8 @@ 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;
+ // A command pane stays the directory's it was run for (newCommand).
+ if (pane.command != null) return;
const cur = switch (pane.cwd) {
.owned => |dir| dir,
else => "",
@@ -4777,8 +4803,10 @@ pub const Pardes = struct {
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.body.mode = .normal;
+ .eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
+ exec.commandDone(p, e.pane, e.status);
+ } else p.removePane(e.pane, null) catch |err| {
+ pane.body.mode = .normal;
p.reportError(e.pane, "terminal exited; Del retries close", err);
},
.lsp_resp => |r| p.lspResponse(r.id, r.rows),
diff --git a/src/tagline.zig b/src/tagline.zig
index 92c18510..8b214fa6 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -21,6 +21,7 @@ pub const file_pane_tail = " " ++ config.file_pane_builtins_str;
pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
+pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
const dirty_marker = " *";
pub const legacy_pane_tail = " Del";
pub const legacy_file_pane_tail = " Save Del";
@@ -109,6 +110,14 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
if (comptime pdf_enabled) if (pane.pdf) |pv|
return std.fmt.allocPrint(arena, "{s} [{d}/{d}]", .{ pv.path, pv.page + 1, pv.page_count });
if (pane.image) |*state| return panes.Image.tagPrefix(arena, state);
+ // A command pane: its directory, the line it runs, and how that went.
+ if (pane.command) |line| {
+ const shown = line[0..@min(line.len, 48)];
+ const more = if (shown.len < line.len) "..." else "";
+ if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running", .{ pane.cwdSlice(), shown, more });
+ if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}", .{ pane.cwdSlice(), shown, more, n });
+ return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?", .{ pane.cwdSlice(), shown, more });
+ }
if (pane.file) |f| {
const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
@@ -148,6 +157,7 @@ pub fn defaultTail(pane: *const Pane) []const u8 {
if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail;
if (panes.File.supportsContext(pane)) return context_pane_tail;
if (pane.file != null) return file_pane_tail;
+ if (pane.command != null) return command_pane_tail;
if (pane.isTerminal()) return terminal_pane_tail;
return pane_tail;
}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 4c1e2db7..490958ee 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1006,13 +1006,15 @@ const Shell = struct {
return true;
},
.pty_eof => |e| if (s.gens[e.id] == e.gen) {
+ var status: ?u8 = null;
if (s.ptys[e.id]) |*pt| {
pt.reader.await(s.io) catch {}; // reader just finished; join it or its future leaks
_ = libc.close(pt.file.handle);
- host_io.retireShell(pt.pid);
+ status = host_io.exitStatus(pt.pid, 100);
+ if (status == null) host_io.retireShell(pt.pid);
s.ptys[e.id] = null;
}
- core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
+ core.update(.{ .eof = .{ .pane = @intCast(e.id), .status = status } });
},
.key_press => |key| if (s.in_paste) {
const bytes = pasteBytes(key);
diff --git a/test/snapshots/chordcut.golden b/test/snapshots/chordcut.golden
index b814d888..42725e42 100644
--- a/test/snapshots/chordcut.golden
+++ b/test/snapshots/chordcut.golden
@@ -30,11 +30,11 @@
== snap cancelexec grid=120x31 cursor=25,8
|8: 2 2echo B''OO g.txt
== snap execafter grid=120x31 cursor=25,8
-|3: f.txt ls
-|4: BOO f.txt g.txt
-|7: 1 line 1 Exec
+|6: /tmp/pardes-snap/chordcut/cwd/f.txt * Save Tty Collapse D /tmp/pardes-snap/chordcut/cwd (echo B''OO) exit 0 Kill Sa
+|7: 1 line 1 Exec BOO
+|8: 2 2echo B''OO g.txt exit 0
== snap cancellook grid=120x31 cursor=25,8
-|7: 1 line 1
+|7: 1 line 1 BOO
== snap lookafter grid=120x31 cursor=7,13
|12: /tmp/pardes-snap/chordcut/cwd/g.txt Save Tty Collapse Del
|13: 1 line 1
@@ -44,10 +44,10 @@
|0: 0-70 #b2c3b8,#1d2824, 71-119 d,#1d2824,
|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-59 d,#26362d, 60-61 #ffffff,#887455, 62-62 d,#1d2824, 63-94 #b2c3b8,#1d2824, 95-119 d,#1d2824,
|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-35 #d3b0d9,#1d2824, 36-59 #b2c3b8,#1d2824, 60-61 #ffffff,#496656, 62-62 d,#1d2824, 63-92 #b2c3b8,#1d2824, 93-95 #d3b0d9,#1d2824, 96-119 #b2c3b8,#1d2824,
-|3: 0-0 d,#152019, 1-1 d,#0d1410, 2-8 #e5eee5,#0d1410, 9-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
+|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-59 #e5eee5,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-59 #e5eee5,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
-|5: 0-0 d,#152019, 1-59 d,#0d1410, 60-60 d,#608269, 61-119 d,#0d1410,
-|6: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-37 #d3b0d9,#1d2824, 38-59 #b2c3b8,#1d2824, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
+|5: 0-0 d,#608269, 1-59 d,#0d1410, 60-60 d,#608269, 61-119 d,#0d1410,
+|6: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-37 #d3b0d9,#1d2824, 38-59 #b2c3b8,#1d2824, 60-61 #ffffff,#496656, 62-62 d,#1d2824, 63-119 #b2c3b8,#1d2824,
|7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-24 #e5eee5,#0d1410, 25-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410,
diff --git a/test/snapshots/cmdword.golden b/test/snapshots/cmdword.golden
index 99ad446b..0b6d91aa 100644
--- a/test/snapshots/cmdword.golden
+++ b/test/snapshots/cmdword.golden
@@ -4,21 +4,24 @@
| /tmp/pardes-snap/cmdword/cwd/cmds.txt Save Tty Collapse Del
| 1 @`echo ZZ''E` @`echo ZZ''L` Look p.txt @`Look .`
== snap atexec grid=120x31 cursor=7,3
-|17: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del
-|18: echo ZZ''E
-|19: ZZE
-== snap atlook grid=120x31 cursor=4,22
-|20: echo ZZ''L
+|17: /tmp/pardes-snap/cmdword/cwd (echo ZZ''E) exit 0 Kill Save Collapse Del
+|18: ZZE
+|19: exit 0
+== snap atlook grid=120x31 cursor=2,23
+|17: /tmp/pardes-snap/cmdword/cwd (echo ZZ''L) exit 0 Kill Save Collapse Del
+|20: % echo ZZ''L
|21: ZZL
+|22: exit 0
== snap lookword grid=120x31 cursor=7,11
|10: /tmp/pardes-snap/cmdword/cwd/p.txt Save Tty Collapse Del
|11: 1 just text
-== snap nested grid=120x31 cursor=4,24
-|22: ls
-|23: cmds.txt p.txt zzmark.txt
-== snap execword grid=120x31 cursor=4,24
-|17: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec
-== snap tagcmd grid=120x31 cursor=4,24
-|17: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec @`echo ZZ''T`
-|24: echo ZZ''T
-|25: ZZT
+== snap nested grid=120x31 cursor=4,9
+|6: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del
+|7: ls
+|8: cmds.txt p.txt zzmark.txt
+== snap execword grid=120x31 cursor=4,9
+|6: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec
+== snap tagcmd grid=120x31 cursor=4,7
+|6: /tmp/pardes-snap/cmdword/cwd Tty Save Mode Filter Collapse Del Exec @`echo ZZ''T`
+|7: echo ZZ''T
+|8: ZZT
diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap
index 361e37ba..631fab8b 100644
--- a/test/snapshots/cmdword.snap
+++ b/test/snapshots/cmdword.snap
@@ -7,9 +7,10 @@
# whole run instead, which is what makes a single CLICK the way you invoke one
# (acme does the same for its own `<`/`|`/`>` command words).
#
-# EXECUTE runs it and leaves you where you were; LOOK runs it and lands you in
-# the shell that answered — looking at a thing means being shown it, and a
-# directory's look has always been exactly that (a shell there, ls'd).
+# EXECUTE runs it, as a command pane, and leaves you where you were; LOOK runs
+# it and lands you in the pane that answered — looking at a thing means being
+# shown it, and a directory's look has always been exactly that (a shell
+# there, ls'd).
#
# And Look and Exec are ORDINARY BUILTINS now, so their names are executable
# words like Save or Grep: `Look p.txt` run off the page is the same look a
@@ -28,14 +29,15 @@ wait 8000 cmds.txt
stable 700 20000
snap opened
# a single middle CLICK inside the run (col 11 is inside `echo`) expands to the
-# whole `@`echo ZZ''E`` and executes it in a shell; focus stays on the file
+# whole `@`echo ZZ''E`` and runs it as a command pane; focus stays on the file
press middle 11 4
release middle 11 4
wait 10000 ZZE
stable 700 15000
snap atexec
-# LOOK on the second one (col 24, inside its `echo`) runs it too AND focuses
-# the shell that answered — that is the whole difference between the two verbs
+# LOOK on the second one (col 24, inside its `echo`) runs it too, in the same
+# command pane now its first is done, below it, AND focuses it — that is the
+# whole difference between the two verbs
press right 24 4
release right 24 4
wait 10000 ZZL
@@ -49,16 +51,16 @@ wait 10000 just text
stable 700 15000
snap lookword
# nesting: `@`Look .`` — Exec unwraps it, `Look .` is parsed as the ordinary
-# Look builtin with an argument, and Look opens the directory (an existing
-# shell there, ls'd — zzmark.txt is only ever in that listing)
+# Look builtin with an argument, and Look opens the directory (a shell there,
+# ls'd — zzmark.txt is only ever in that listing; a command pane is no shell)
press middle 49 4
release middle 49 4
wait 10000 zzmark.txt
stable 700 15000
snap nested
# the same syntax typed into a TAG — the other place the expansion has to know
-# about backticks. The look above left the shell focused, so `:` opens ITS
-# tag; `A` appends at the end of it, Esc and `h` put the normal-mode cursor on
+# about backticks. The look above left that shell focused, so `:` opens ITS
+# tag, and the word run from a shell's own tag is typed into it; `A` appends at the end of it, Esc and `h` put the normal-mode cursor on
# the last character typed, and the execute key runs the word under it
# exactly as the body's does.
key :
diff --git a/test/snapshots/layout-open.golden b/test/snapshots/layout-open.golden
index f682924a..2bfdde63 100644
--- a/test/snapshots/layout-open.golden
+++ b/test/snapshots/layout-open.golden
@@ -31,6 +31,6 @@
|21: /tmp/pardes-snap/layout-open/cwd/sub/f.txt Save
|22: 1 echo 'TTY'OK
== snap ttydir grid=100x31 cursor=7,22
-|10: /tmp/pardes-snap/layout-open/cwd/sub Tty Save M
-|11: echo 'TTY'OK
-|12: TTYOK
+|10: /tmp/pardes-snap/layout-open/cwd/sub (echo 'TTY
+|11: TTYOK
+|12: exit 0
diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap
index d7d7e19e..04770f27 100644
--- a/test/snapshots/layout-open.snap
+++ b/test/snapshots/layout-open.snap
@@ -1,5 +1,5 @@
# every pane open splits ONE existing pane, the rest of the column keeps its
-# sizes: Alt-n splits the active pane, an execute-spawned shell for a file's
+# sizes: Alt-n splits the active pane, an execute-spawned command pane for a file's
# dir splits only the bottom pane of the rightmost column. At this narrow
# width the first looked-up file also splits below its source, leaving every
# unrelated pane (including the ones above the source) at its exact rectangle.
@@ -44,9 +44,9 @@ press right 7 28
release right 7 28
stable 700 15000
snap file-open
-# execute the file's command (middle drag over line 1): no shell has cwd sub/,
-# so one is forked at the bottom of the rightmost column, splitting only the
-# pane above it
+# execute the file's command (middle drag over line 1): it runs as a command
+# pane for sub/, forked at the bottom of the rightmost column, splitting only
+# the pane above it
press middle 8 23
drag middle 19 23
release middle 19 23
diff --git a/test/snapshots/ttytaken.golden b/test/snapshots/ttytaken.golden
index 72708e64..3fb29738 100644
--- a/test/snapshots/ttytaken.golden
+++ b/test/snapshots/ttytaken.golden
@@ -1,47 +1,48 @@
-== snap opened grid=210x31 cursor=7,3
+== snap opened grid=210x31 cursor=4,5
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
-| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
-| /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save Tty Collapse Del /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del
-| 1 echo TAKEN''ONE ls
-| 2 echo TAKEN''TWO big.txt cmd.txt
-== snap at-prompt grid=210x31 cursor=7,3
-|3: 1 echo TAKEN''ONE Exec ls
-|5: echo TAKEN''ONE
-|6: TAKENONE
-== snap paging grid=210x31 cursor=114,30
-|2: /tmp/pardes-snap/ttytaken/cwd/cmd.txt Save Tty Collapse Del $ /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del
-|3: 1 echo TAKEN''ONE line 1
-|4: 2 echo TAKEN''TWO line 2
-|5: line 3
-|6: line 4
-|7: line 5
-|8: line 6
-|9: line 7
-|10: line 8
-|11: line 9
-|12: line 10
-|13: line 11
-|14: line 12
-|15: line 13
-|16: line 14
-|17: line 15
-|18: line 16
-|19: line 17
-|20: line 18
-|21: line 19
-|22: line 20
-|23: line 21
-|24: line 22
-|25: line 23
-|26: line 24
-|27: line 25
-|28: line 26
-|29: line 27
-|30: big.txt
-== snap spawned grid=210x31 cursor=108,26
-|3: 1 echo TAKEN''ONE Exec line 1
-|26: :
-|27: /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del
-|28: echo TAKEN''TWO
-|29: TAKENTWO
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del
+| ls
+| big.txt
+== snap at-prompt grid=210x31 cursor=4,5
+|2: /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del @`echo TAKEN''ONE`
+|3: ls Exec
+|5: echo TAKEN''ONE
+|6: TAKENONE
+== snap paging grid=210x31 cursor=9,30
+|3: line 1
+|4: line 2
+|5: line 3
+|6: line 4
+|7: line 5
+|8: line 6
+|9: line 7
+|10: line 8
+|11: line 9
+|12: line 10
+|13: line 11
+|14: line 12
+|15: line 13
+|16: line 14
+|17: line 15
+|18: line 16
+|19: line 17
+|20: line 18
+|21: line 19
+|22: line 20
+|23: line 21
+|24: line 22
+|25: line 23
+|26: line 24
+|27: line 25
+|28: line 26
+|29: line 27
+|30: big.txt
+== snap spawned grid=210x31 cursor=104,2
+|2: /tmp/pardes-snap/ttytaken/cwd Tty Save Mode Filter Collapse Del @`echo TAKEN''ONE` @`echo TAKEN''TWO`
+|3: line 1 Exec
+|26: :
+|27: /tmp/pardes-snap/ttytaken/cwd (echo TAKEN''TWO) exit 0 Kill Save Collapse Del
+|28: TAKENTWO
+|29: exit 0
|30:
diff --git a/test/snapshots/ttytaken.snap b/test/snapshots/ttytaken.snap
index 47554876..a6ccb632 100644
--- a/test/snapshots/ttytaken.snap
+++ b/test/snapshots/ttytaken.snap
@@ -1,43 +1,50 @@
-# Process monitoring, end to end. A pane whose tty is held by a full-screen
-# program is not somewhere a command line may be typed: the SAME middle-drag
-# Exec goes into the shell while it sits at its prompt, and into a NEW shell
-# beside it once `less` owns the terminal — where it runs, instead of becoming
-# keystrokes the pager would eat. Nothing about the pager's screen changes.
-file cmd.txt echo TAKEN''ONE\necho TAKEN''TWO
+# Process monitoring, end to end. A terminal whose tty is held by a
+# full-screen program is not somewhere a command line may be typed: the SAME
+# Exec from that terminal goes into its shell while it sits at its prompt,
+# and into a command pane of its own once `less` owns the terminal -- where
+# it runs, instead of becoming keystrokes the pager would eat. Nothing about
+# the pager's screen changes. (From any pane but a terminal, an Exec always
+# runs as a command pane: exec.snap and cmdword.snap.)
lines big.txt 40
start 31 210 -n 1
-wait 8000 cmd.txt
-stable 700 20000
-# open the command file by looking its name in the greeting `ls`
-press right 14 5
-release right 14 5
-wait 10000 echo TAKEN
+wait 8000 big.txt
stable 700 20000
snap opened
-# at the prompt: the drag runs in the shell that is already on this directory
-press middle 8 4
-drag middle 22 4
-release middle 22 4
+# at the prompt: a command word in the terminal's own tag, executed, is
+# typed into its shell. `:` opens the tag, `A` appends, and the execute key
+# runs the word under the cursor.
+key :
+key A
+key space
+text @`echo TAKEN''ONE`
+key esc
+settle 100
+key h
+key tab
wait 10000 TAKENONE
stable 700 20000
snap at-prompt
-# ...and now a pager owns that terminal. Ctrl-b is raw tty (config.tty_toggle),
-# which is how a script types AT the shell instead of into the pane's own edit
-# buffer — the same way exec.snap runs a command line.
-press left 150 9
-release left 150 9
-stable 400 5000
+# ...and now a pager owns that terminal. Ctrl-b is raw tty
+# (config.tty_toggle), which is how a script types AT the shell instead of
+# into the pane's own edit buffer.
key c-b
stable 400 5000
text env LESS= less big.txt
key enter
wait 10000 line 1
stable 700 20000
+key c-b
+stable 400 5000
snap paging
-# the same gesture on the second line: a new shell, not `less` keystrokes
-press middle 8 5
-drag middle 22 5
-release middle 22 5
+# the same gesture from that terminal: a command pane, not `less` keystrokes
+key :
+key A
+key space
+text @`echo TAKEN''TWO`
+key esc
+settle 100
+key h
+key tab
wait 10000 TAKENTWO
stable 700 20000
snap spawned