diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 12 | ||||
| -rw-r--r-- | docs/fs.md | 27 | ||||
| -rw-r--r-- | docs/open-questions.md | 17 | ||||
| -rw-r--r-- | src/builtins.zig | 15 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/detached/server.zig | 5 | ||||
| -rw-r--r-- | src/exec.zig | 122 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 8 | ||||
| -rw-r--r-- | src/host_io.zig | 64 | ||||
| -rw-r--r-- | src/macos.zig | 8 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 8 | ||||
| -rw-r--r-- | src/panes.zig | 7 | ||||
| -rw-r--r-- | src/pardes.zig | 200 | ||||
| -rw-r--r-- | src/tagline.zig | 10 | ||||
| -rw-r--r-- | src/tty/tty.zig | 6 | ||||
| -rw-r--r-- | test/snapshots/chordcut.golden | 14 | ||||
| -rw-r--r-- | test/snapshots/cmdword.golden | 31 | ||||
| -rw-r--r-- | test/snapshots/cmdword.snap | 22 | ||||
| -rw-r--r-- | test/snapshots/layout-open.golden | 6 | ||||
| -rw-r--r-- | test/snapshots/layout-open.snap | 8 | ||||
| -rw-r--r-- | test/snapshots/ttytaken.golden | 91 | ||||
| -rw-r--r-- | test/snapshots/ttytaken.snap | 59 |
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`. @@ -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 |
