summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 23:38:31 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit8469def61324feb5e278f88bebad36af7a0d997e (patch)
tree2deeb598b94a517bfc977226d28cb7d0ffc11e4c
parentaae6fc630d9d77de9ad975414d003a55d5b9d1e3 (diff)
downloadpardes-8469def61324feb5e278f88bebad36af7a0d997e.tar.gz
pardes-8469def61324feb5e278f88bebad36af7a0d997e.zip
A send to Python's REPL runs once, the Enter after the paste, not in it
A one-line send (print(1)) to Python 3.14 stopped at ... and needed another Enter: its REPL takes a bracketed paste, and an Enter read in the same read as the paste is part of the paste, one line or not (checked in a real pty). Into a REPL that asked for bracketed paste, the Enters (two for an open block) now wait for its next output, its answer to the paste, or 250 ms with nothing. fs-test runs python3 in a real pty and checks a one-line exec prints at once; the docs' pty/data recipe says to send the Enter apart. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--.agents/skills/pardes-9p/SKILL.md8
-rw-r--r--docs/fs.md10
-rw-r--r--src/exec.zig25
-rw-r--r--src/panes.zig4
-rw-r--r--src/pardes.zig29
-rw-r--r--test/fs.py47
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`.
diff --git a/docs/fs.md b/docs/fs.md
index 5afb2614..70eb3e18 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -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;
}
diff --git a/test/fs.py b/test/fs.py
index 890e75f6..81989f88 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -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'),