From e7c181d6238dae4b08a02c1a9828917c2497f89d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:20:56 -0300 Subject: A command line runs as its own command pane unless it is clicked at a shell's prompt A middle click, an exec write or a tag word that no builtin knows was typed into some terminal for the pane's directory, sharing whatever state that shell was in and answering nothing, so a misspelling vanished into a shell. Now only a line clicked at an interactive terminal's prompt is typed there. From anywhere else it runs as a command pane: a terminal whose child is $SHELL -c the line in the pane's directory, full emulation, which shows its output and then exit N from the host's reaping of the child, and stays. A finished command pane is the next command's for its directory, which runs below what it showed after a '% line' line (acme appends to +Errors and never clears it, util.c:213); a running one gets a second pane. Kill ends a command pane's whole process group, the log records run and exit, exec reads back the command pane's serial, and a line is at most 1 KB, read off the pane as the host forks rather than carried in every spawn effect. ttyForDir's search for a free shell is gone. The goldens of chordcut, cmdword and layout-open change where a file's exec now opens a command pane, and ttytaken is rewritten to exec from the terminal itself; docs/open-questions.md records the decision. Co-Authored-By: Claude Opus 5.5 --- src/exec.zig | 122 ++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 27 deletions(-) (limited to 'src/exec.zig') 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 ` or `exit ` 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 -- cgit v1.3