diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 11:22:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 1c3842f136b9f98fae784559ec12d66d629c5909 (patch) | |
| tree | 9a53e64542e191b717052dbe5b5cd79cfd2c0754 /src | |
| parent | 5cf50930dee214fb641bb95bbf8639b8f76930d9 (diff) | |
| download | pardes-1c3842f136b9f98fae784559ec12d66d629c5909.tar.gz pardes-1c3842f136b9f98fae784559ec12d66d629c5909.zip | |
Exit quits and Kill stops commands, as in acme
Kill quit the editor, which in acme is Exit; acme's Kill stops the
commands it started. Exit now quits as acme's does (exec.c, rowclean):
it refuses once, naming each pane with unsaved text, and quits when
asked again with nothing edited since (a small scratch is not asked
about). Kill, bare or with names, stops the commands pardes typed into a
terminal (an exec, a middle click, a pty/run) while their shell's marks
say they run, by SIGTERM to the terminal's foreground job, never to the
shell (acme posts the kill note, which terminates). Both are session
builtins; the topbar's Kill becomes Exit, same width, and every golden's
topbar row changed by exactly that word (checked line by line); the
builtins script scrolls one more row for the index's new line.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/Terminal.zig | 23 | ||||
| -rw-r--r-- | src/builtins.zig | 99 | ||||
| -rw-r--r-- | src/config.zig | 9 | ||||
| -rw-r--r-- | src/exec.zig | 1 | ||||
| -rw-r--r-- | src/fs-help.txt | 2 | ||||
| -rw-r--r-- | src/host_io.zig | 25 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 1 | ||||
| -rw-r--r-- | src/panes.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 12 | ||||
| -rw-r--r-- | src/tutor.txt | 7 |
10 files changed, 173 insertions, 12 deletions
diff --git a/src/Terminal.zig b/src/Terminal.zig index 0bec3ce8..cd292e5f 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -214,6 +214,29 @@ pub fn armShellSpawn(pane: *Pane) void { pane.pending_command.wait = .spawn; } +/// Remembers a line pardes types into this terminal as the command it +/// started, for Kill: it runs from the shell's next C mark to its D. +/// ponytail: of several lines queued for a shell not up yet, only the last +/// is remembered, as the first of them. +pub fn noteCommand(pane: *Pane, line: []const u8) void { + if (comptime !enabled) return; + const state = pane.terminal orelse return; + const word = std.mem.trim(u8, line, " \t")[0 .. std.mem.indexOfAny(u8, std.mem.trim(u8, line, " \t"), " \t") orelse std.mem.trim(u8, line, " \t").len]; + var sent: @typeInfo(@FieldType(Pane, "sent_command")).optional.child = .{ .seq = state.stream.handler.started +% 1 }; + sent.len = @intCast(@min(word.len, sent.word.len)); + @memcpy(sent.word[0..sent.len], word[0..sent.len]); + pane.sent_command = sent; +} + +/// Whether the command pardes last typed here is running now. +pub fn commandRunning(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + const sent = pane.sent_command orelse return false; + const marks = &state.stream.handler; + return marks.phase == .running and marks.started == sent.seq; +} + pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { if (pane.pending_command.wait == .none) return false; const old_len = pane.pending_command.bytes.len; diff --git a/src/builtins.zig b/src/builtins.zig index 2dac8a59..0154e707 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -213,6 +213,56 @@ test "a builtin's scope is the pane unless it says session, and every setting is try std.testing.expectEqual(Scope.session, registry.scope(std.meta.stringToEnum(Builtin, setting.word).?)); } +test "Exit asks once about unsaved text, and quits when asked again" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("saved\n"); + pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified + try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); + try std.testing.expect(!p.quit); + try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified")); + // Told once, and nothing edited since: the second Exit quits. + try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); + try std.testing.expect(p.quit); +} + +test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const term = p.active; + try std.testing.expect(p.panes[term].?.isTerminal()); + const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; + p.update(.{ .output = .{ .pane = @intCast(term), .bytes = prompt } }); + while (p.nextEffect()) |_| {} + const killed = struct { + fn count(pp: *Pardes, line: []const u8) usize { + _ = pp.executeBuiltinLine(pp.active, line); + var n: usize = 0; + while (pp.nextEffect()) |e| if (e == .signal_pty and e.signal_pty.sig == .command) { + n += 1; + }; + return n; + } + }; + // Nothing running: nothing to stop, and never the shell. + try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); + _ = exec.execute(p, term, "make -j8"); + while (p.nextEffect()) |_| {} + // Typed but not started yet (no C mark): not running. + try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); + p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;C;aid=pardes\x07" } }); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill ls")); + try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill ls make")); + try std.testing.expectEqual(@as(usize, 1), killed.count(p, "Kill")); + try std.testing.expect(!p.quit); + // Ended (a D mark): nothing to stop again. + p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;D;143;aid=pardes\x07" ++ prompt } }); + while (p.nextEffect()) |_| {} + try std.testing.expectEqual(@as(usize, 0), killed.count(p, "Kill")); +} + test "Look does not announce itself on the message row; Del still does" { const Builtin = registry.Builtin(); try std.testing.expect(!registry.announces(Builtin.Look)); @@ -274,14 +324,61 @@ pub const Exec = struct { // ---- session ---- -pub const Kill = struct { +/// acme's Exit (editors/acme/exec.c, `exit` and rowclean): quit, unless a +/// pane holds unsaved text nobody has been told about. Each such pane says +/// so, `<name>: Modified`, and the Exit goes no further; an Exit after that, +/// with nothing edited since, quits. A scratch of under 100 bytes is no +/// loss worth the question, as acme's winclean does not ask for one. +pub const Exit = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { + var modified = false; + for (c.p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const f = pane.file orelse continue; + if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue; + if (f.output != null and f.content.len < 100) continue; + if (pane.exit_warned == f.revision) continue; + pane.exit_warned = f.revision; + c.p.reportError(id, f.path, error.Modified); + modified = true; + } + if (modified) return; c.p.quit = true; c.p.emit(.quit); } }; +/// acme's Kill (exec.c, `kill`; acme.c's WKill): stop the commands pardes +/// started -- the lines it typed into a terminal, from an exec, a middle +/// click or a pty/run, while their shell says they run (C mark to D). +/// Bare, every one; `Kill make ls`, those whose line starts with one of +/// the words. acme posts the "kill" note, which ends a process; here that +/// is SIGTERM to the terminal's foreground job, and never to the shell, +/// which is its own job and stays. +pub const Kill = struct { + pub const scope: Scope = .session; + pub const takes_arg = true; + pub fn run(c: Ctx) void { + const names = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + var stopped = false; + for (c.p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (!panes.Terminal.commandRunning(pane)) continue; + const sent = pane.sent_command.?; + if (names.len > 0) { + var words = std.mem.tokenizeAny(u8, names, " \t"); + while (words.next()) |w| { + if (std.mem.eql(u8, w, sent.word[0..sent.len])) break; + } else continue; + } + c.p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = .command } }); + stopped = true; + } + if (!stopped and names.len > 0) c.p.reportError(c.id, "Kill", error.NoSuchCommand); + } +}; + pub const Dump = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { diff --git a/src/config.zig b/src/config.zig index f898df11..6d0cf0a6 100644 --- a/src/config.zig +++ b/src/config.zig @@ -49,6 +49,7 @@ pub const leader_path = paths: { .Togglettymode = null, .Collapse = null, .Kill = null, + .Exit = null, .ClipYank = "y", .ClipYankMain = "Y", .ClipPaste = "p", @@ -160,8 +161,9 @@ pub const leader_path = paths: { break :paths table; }; -test "Space-k is unbound while Kill and other k chords remain available" { +test "Space-k is unbound while Exit, Kill and other k chords remain available" { try std.testing.expect(leader_path.get(.Kill) == null); + try std.testing.expect(leader_path.get(.Exit) == null); try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?); try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?); try std.testing.expectEqualStrings("d", leader_path.get(.Del).?); @@ -183,7 +185,10 @@ test "Space-k is unbound while Kill and other k chords remain available" { while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row); + // Kill stops commands and never quits; Exit quits. try std.testing.expect(p.executeBuiltinLine(p.active, "Kill")); + try std.testing.expect(!p.quit); + try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } @@ -237,7 +242,7 @@ pub const child_term = "xterm-256color"; pub const child_colorterm = "truecolor"; pub const child_term_program = "pardes"; -pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; +pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit"; pub const pane_builtins_str = "Tty Collapse Del"; pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del"; diff --git a/src/exec.zig b/src/exec.zig index 46b4f57b..b16c600c 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -399,6 +399,7 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { 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| { p.reportError(id, "queue command", err); return dst; diff --git a/src/fs-help.txt b/src/fs-help.txt index 8db1e288..bbff6f13 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -9,7 +9,7 @@ log events: new/del/rename/save <serial> <name>, msg <serial|-> <text>, screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses focus the serial of the pane with the keyboard; write a serial to give it the keyboard -ctl the settings, one a line as a write takes them; write one, or Newcol, Dump; Kill QUITS +ctl settings, one a line as a write takes them; or Newcol, Dump, Exit (quits), Kill (stops cmds) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it pane/new open it to make a pane (named +New); the read answers that pane's serial pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: diff --git a/src/host_io.zig b/src/host_io.zig index a5efc7d1..5d9dd413 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1337,8 +1337,14 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna .hup => libc.SIG.HUP, .quit => libc.SIG.QUIT, .kill => libc.SIG.KILL, + .command => libc.SIG.TERM, }; const fg = tcgetpgrp(master_fd); + // The shell leads its own group: a job it runs has another. + if (which == .command) { + if (fg > 0 and fg != shell_pid) _ = libc.kill(-fg, sig); + return; + } if (fg > 0) { _ = libc.kill(-fg, sig); return; @@ -1640,6 +1646,25 @@ test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands i try std.testing.expect(sh.waitText(test_prompt, 10_000)); } +test "Kill's signal stops the foreground job and never the shell" { + if (comptime !tty_probe_platform) return error.SkipZigTest; + var sh = TestShell.start() orelse return error.SkipZigTest; + defer sh.stop(); + + // At the prompt the shell is the foreground: there is nothing to stop. + signalTty(sh.pid, sh.master, .command); + sleepMs(100); + try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); + + sh.send("sleep 30\n"); + try std.testing.expect(sh.waitTaken(true, 10_000)); + sh.forget(); + signalTty(sh.pid, sh.master, .command); + try std.testing.expect(sh.waitTaken(false, 10_000)); + try std.testing.expect(sh.waitText(test_prompt, 10_000)); + try std.testing.expectEqual(@as(libc.pid_t, 0), libc.waitpid(sh.pid, null, libc.W.NOHANG)); +} + test "a background job is not the tty's owner" { if (comptime !tty_probe_platform) return error.SkipZigTest; var sh = TestShell.start() orelse return error.SkipZigTest; diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 50d14624..dcf9a8a7 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -233,6 +233,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { slot.declined = marks.declined; p.emitWrite(id, slot.line); p.emitWrite(id, "\r"); + pardes.panes.Terminal.noteCommand(pane, slot.line); slot.phase = .sent; return; } diff --git a/src/panes.zig b/src/panes.zig index ab4f5072..70b461eb 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -95,6 +95,12 @@ pub const Pane = struct { rows: u16, greet: bool = false, pending_command: Terminal.PendingCommand = .{}, + /// The last command line pardes typed into this terminal (an exec, a + /// 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, + /// The revision an Exit last warned this pane was modified at. + exit_warned: ?u32 = null, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, file: ?File.State = null, diff --git a/src/pardes.zig b/src/pardes.zig index 05f6710a..f3348ea5 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -561,7 +561,7 @@ test "startup config runs builtin lines in order and isolates bad lines" { .startup_config = \\Theme dark \\DefinitelyNotABuiltin - \\Kill trailing-garbage + \\Exit trailing-garbage \\Theme no-such-theme \\Theme acme , @@ -575,7 +575,7 @@ test "startup config runs builtin lines in order and isolates bad lines" { for (0..p.effects_len) |i| switch (p.effects[(p.effects_head + i) % p.effects.len]) { .write => |w| { try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "DefinitelyNotABuiltin") == null); - try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Kill") == null); + try std.testing.expect(std.mem.indexOf(u8, w.bytes.slice(), "Exit") == null); }, else => {}, }; @@ -668,10 +668,10 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { else => {}, }; - // Fixed-name builtins remain exact: a tail does not turn New or Kill into + // Fixed-name builtins remain exact: a tail does not turn New or Exit into // a match (especially important for the destructive one). try std.testing.expect(!p.executeBuiltinLine(0, "New argument")); - try std.testing.expect(!p.executeBuiltinLine(0, "Kill trailing-garbage")); + try std.testing.expect(!p.executeBuiltinLine(0, "Exit trailing-garbage")); try std.testing.expect(!p.quit); // A word absent from the builtin vocabulary takes the fallback, including @@ -3083,7 +3083,9 @@ pub const AttachRequest = struct { pane: u8, name: []const u8 }; pub const PendingWrite = struct { bytes: []u8, off: usize = 0 }; -pub const PtySignal = enum(u8) { int, term, hup, quit, kill }; +/// `command` is Kill's: SIGTERM to the terminal's foreground job, and never +/// to the shell itself when the shell is in the foreground. +pub const PtySignal = enum(u8) { int, term, hup, quit, kill, command }; /// IO the core wants done. Payloads are inline (fixed buffers): effects are /// queued values with no lifetime ties back into the core. diff --git a/src/tutor.txt b/src/tutor.txt index 2f5b521c..507ce40e 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -66,7 +66,7 @@ The top bar is: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor - Debug Kill + Debug Exit Reach it from the keyboard with `k` off the topmost tagline; `j` comes back down. @@ -361,7 +361,7 @@ typed abandons it, and so does any key that leads nowhere. SPC ? list every path - SPC d Del Kill remains available in the topbar. + SPC d Del Exit (quit) remains available in the topbar. SPC f s Save SPC f f Find SPC f n New SPC y Y p P R the system clipboard SPC w h/j/k/l focus a neighbouring pane @@ -488,4 +488,5 @@ typed Spawn this tutor again: middle-click "Tutor" in the top bar, or SPC h t. Quit: this is a file pane — `:q` is not wired. Close it with Del in its - tag, "Kill" in the top bar to quit everything, or Ctrl-c the app. + tag, "Exit" in the top bar to quit everything, or Ctrl-c the app. + "Kill" does not quit: it stops the commands pardes started, as acme's does. |
