diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 8 | ||||
| -rw-r--r-- | docs/fs.md | 10 | ||||
| -rw-r--r-- | src/exec.zig | 25 | ||||
| -rw-r--r-- | src/panes.zig | 4 | ||||
| -rw-r--r-- | src/pardes.zig | 29 | ||||
| -rw-r--r-- | test/fs.py | 47 |
6 files changed, 112 insertions, 11 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 39a6b1ce..b328334c 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -149,9 +149,11 @@ would. `Repl -` unbinds; a bare `Repl` says the binding. With several REPLs bound for a language an exec asks which, logged `ask <serial> repl a b` (Del's side from the keyboard is `ask <serial> del k j`): answer with `echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing. Multi-line code -written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~` -then `\r`, and a second `\r` when the code's last line is indented (a `def` -or `for` body): one Enter leaves such a block open. Sent line by line, a blank line ends a Python block, and Python +written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~`, +then, in a separate write once the REPL has echoed the paste (Python 3.13+ +takes a `\r` read with the paste as part of it, even for one line), `\r`, +and a second `\r` when the code's last line is indented (a `def` or `for` +body): one Enter leaves such a block open. Sent line by line, a blank line ends a Python block, and Python 3.14's REPL auto-indents each line it is typed. Every refused 9P write adds an `err <serial|-> <file>: <why>` record to `$m/log`; through a mount the write itself only says `Invalid argument`. @@ -317,10 +317,12 @@ the tag is how to run `make` from that file), `Exec <text>` run by name (typed, a 2-1 chord onto `Exec`, a `ctl` line) and a command word @`cmd` in the text, looked at or clicked (`# @`pytest -x`` in a script). A 9P `exec` is no gesture and is never sent: a script writes to the REPL pane's -`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~` -then `\r`, and a second `\r` when its last line is indented: one Enter -leaves a block open, pasted or typed; a middle click sends that second one -itself), since line by line a blank line ends a Python block and Python +`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~`, +then `\r` in a write of its own once the REPL has echoed the paste -- +Python 3.13's REPL takes an Enter read with the paste as part of it, even a +one-line one -- and a second `\r` when its last line is indented: one Enter +leaves a block open, pasted or typed; a middle click does all of this +itself, holding the Enter until the REPL answers the paste), since line by line a blank line ends a Python block and Python 3.14's REPL auto-indents each line typed into it. The REPL gets the text wherever it is -- at a `pdb` or `input()` prompt too. Bindings are not dumped, so a Restore leaves none. diff --git a/src/exec.zig b/src/exec.zig index 56e6a2a9..2f279d8e 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -443,6 +443,18 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed { return .asked; } +const repl_enter_wait_ns: u64 = 250 * std.time.ns_per_ms; + +/// Sends the Enters a paste into pane `id`'s REPL is owed: when the REPL +/// has answered it (`answered`), or once the wait is up. +pub fn flushReplEnter(p: *Pardes, id: usize, answered: bool) void { + const pane = p.panes[id] orelse return; + if (pane.repl_enter == 0) return; + if (!answered and p.stepped_ns < pane.repl_enter_due_ns) return; + for (0..pane.repl_enter) |_| p.emitWrite(id, "\r"); + pane.repl_enter = 0; +} + fn pythonBlockOpen(text: []const u8) bool { const trimmed = std.mem.trimEnd(u8, text, " \t\n"); const start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |nl| nl + 1 else 0; @@ -475,13 +487,20 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { var said: [128]u8 = undefined; return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send"); } + const pasted = panes.Terminal.bracketedPaste(dst); edit.typeToTty(p, to, dst, clean[0..n]); - p.emitWrite(to, "\r"); // A Python block whose last line is indented, or a bare header, is not // over at one Enter, pasted into 3.13's REPL or typed into the old one: // the blank line a second Enter gives ends it. - if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n])) - p.emitWrite(to, "\r"); + const enters: u8 = if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n])) 2 else 1; + if (pasted) { + // An Enter read with the paste is part of it: 3.13's REPL takes it + // as a newline in the pasted text, even a one-line one, and waits. + // It goes once the REPL has answered the paste (its next output), + // or after a moment if nothing comes. + dst.repl_enter = enters; + dst.repl_enter_due_ns = p.stepped_ns + repl_enter_wait_ns; + } else for (0..enters) |_| p.emitWrite(to, "\r"); // Line by line, a blank line inside a Python block ends the block. if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and std.mem.indexOfScalar(u8, text, '\n') != null) diff --git a/src/panes.zig b/src/panes.zig index c33fd577..7aea5898 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -135,6 +135,10 @@ pub const Pane = struct { repl: ?struct { lang: u8, letter: u8 } = null, /// It has been told that its program takes no bracketed paste. repl_warned: bool = false, + /// Enters owed to a REPL after a bracketed paste (exec.sendToRepl): sent + /// with its next output, the paste taken, or at `repl_enter_due_ns`. + repl_enter: u8 = 0, + repl_enter_due_ns: u64 = 0, /// A question with its answers in it (`repl_choice`). question: [96]u8 = undefined, question_len: u8 = 0, diff --git a/src/pardes.zig b/src/pardes.zig index c2bde804..ea03b3eb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1638,6 +1638,24 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe _ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true); Sent.drain(p); try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1)); + // Into a REPL that takes a bracketed paste (3.13's), the Enter waits + // for the REPL to answer the paste: read with it, it would be part of it. + p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004h" } }); + Sent.drain(p); + _ = exec.executeFrom(p, 0, "print(1)", true); + Sent.drain(p); + try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1)); + p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } }); + Sent.drain(p); + try std.testing.expectEqualStrings("\r", Sent.said(1)); + // Nothing answering, it goes when its wait is up. + _ = exec.executeFrom(p, 0, "print(2)", true); + p.stepped_ns += 300 * std.time.ns_per_ms; + p.update(.tick); + Sent.drain(p); + try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~\r")); + p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } }); + Sent.len[1] = 0; // A block left open by its indented last line gets the blank line that // ends it: a second Enter. _ = exec.executeFrom(p, 0, "def f():\n return 1", true); @@ -5333,7 +5351,10 @@ pub const Pardes = struct { } p.clip_pending = null; }, - .tick => Messages.advanceMessages(p), + .tick => { + Messages.advanceMessages(p); + for (0..p.panes.len) |id| exec.flushReplEnter(p, id, false); + }, else => {}, } if (p.fs.listeners != 0) p.fs.origin = switch (ev) { @@ -5377,6 +5398,7 @@ pub const Pardes = struct { ctlfs.events.notePtyOutput(p, o.pane, o.bytes); panes.Terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); + exec.flushReplEnter(p, o.pane, true); }, .exited => |e| if (p.panes[e.pane]) |pane| { if (pane.command == null) @@ -6474,6 +6496,11 @@ pub const Pardes = struct { const wait = Messages.messageWake(p, pane) orelse continue; frames = if (frames) |soonest| @min(soonest, wait) else wait; }; + // A paste's Enter waiting on its REPL goes at the end of its wait. + for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) { + const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns) / animation.frame_ns); + frames = if (frames) |soonest| @min(soonest, wait) else wait; + }; return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; } @@ -3,6 +3,7 @@ import contextlib import fcntl import json import os +import shutil import signal from pathlib import Path import subprocess @@ -466,6 +467,51 @@ def unattended_messages_expire(binary): assert not shown(), 'an unattended message outlived MessageLinger' +def python_repl_runs_one_line(binary): + """A one-line exec sent to Python's own REPL (3.13's pyrepl, which asks + for bracketed paste) runs at once, with no second Enter: the Enter + reaches it after the paste, not inside it. A real pty, a real python3.""" + python = shutil.which('python3') + if python is None: + return + version = subprocess.run([python, '-c', 'import sys; print(sys.version_info >= (3, 13))'], + capture_output=True, text=True).stdout.strip() + if version != 'True': + return + with tempfile.TemporaryDirectory(prefix='pardes-repl-') as directory: + with session(binary, Path(directory), 'repl') as (client, _): + def panes(client): + return {int(row.split()[0]) for row in client.read('/index').splitlines()} + before = panes(client) + client.write('/pane/1/ctl', b'Tty\n') + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and panes(client) == before: + time.sleep(.05) + tty = max(panes(client) - before) + def body(): + return client.read(f'/pane/{tty}/body').decode('utf-8', 'replace') + def wait_for(text, seconds=15): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if text in body(): + return True + time.sleep(.1) + return False + assert wait_for('$') or wait_for('>'), body() + client.write(f'/pane/{tty}/pty/data', python.encode() + b' -q\r') + assert wait_for('>>>'), body() + client.write(f'/pane/{tty}/ctl', b'Repl python\n') + # An exec on the REPL's own body goes to the REPL, as its event + # record written back does: an empty range acts on its text. + fid = client.open(f'/pane/{tty}/event', 2) + try: + record = b'MX0 0 0 10 print(6*7)\n' + client.rpc(118, struct.pack('<IQI', fid, 0, len(record)) + record) + assert wait_for('\n42'), body() + finally: + client.close(fid) + + def signal_cleans_up(binary): """A detached session told to stop by a signal unlinks its sockets.""" for sig in [signal.SIGTERM, signal.SIGINT, signal.SIGHUP]: @@ -487,6 +533,7 @@ def test(binary, quic=False): run_file(binary) exit_answers(binary) unattended_messages_expire(binary) + python_repl_runs_one_line(binary) signal_cleans_up(binary) for options, message in [ (['--fs'], b'no such option'), |
