From eeadda6e7db3334e15708de2d7b5396f539c8186 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:31:42 -0300 Subject: A terminal bound as a language's REPL takes the execs made on that language's files Repl python in a terminal's tag or on its ctl binds it (Repl - unbinds, Repl bare says the binding, the same word again changes nothing), with an id, python-a, python-b, in its tag. An exec made by a gesture on the body of a file in that language -- a middle click or the execute key, on any text, a single word too -- or on the REPL's own body is typed into it, bracketed if its program asked for that, else line by line with a once-said warning for Python's blank lines, then Enter; the message row says -> python-a in the tag's name tint, the log send . Several bound for the language: the pane asks which on its notice band, as Del asks, one key answering, Esc none, nothing remembered. The gesture is known where it is dispatched (mouse.zig, the execute key), so the tag's words, Exec run by name and a command word @`cmd` stay commands, and a 9P exec is never sent. A finished command pane is no REPL; bindings are not dumped. The docs spell out the escapes, the pdb and input() caveat and the no-bracketed-paste papercut. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 6 +- docs/fs.md | 21 +++++++ docs/tags.md | 6 +- src/File.zig | 2 +- src/Messages.zig | 1 + src/body_layer.zig | 6 +- src/builtins.zig | 55 +++++++++++++++++ src/config.zig | 1 + src/edit.zig | 2 +- src/exec.zig | 101 ++++++++++++++++++++++++++++++ src/fs-help.txt | 4 +- src/mouse.zig | 5 +- src/panes.zig | 17 +++++ src/pardes.zig | 127 +++++++++++++++++++++++++++++++++++++- src/tagline.zig | 15 ++++- test/snapshots/builtins.snap | 1 + 16 files changed, 354 insertions(+), 16 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index df53c90f..8903a609 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -123,7 +123,11 @@ 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 ` and `exit ` -- `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 +command pane, below what it showed. A terminal bound as a REPL (`Repl +python` on its ctl) takes the middle clicks made on a `.py` body instead, +but never a 9P write: a script sends code by writing the REPL pane's +`pty/data`, and runs a command from such a file with `Exec ` or the +tag. Every refused 9P write adds an `err : ` 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 d3e32e3a..e93704a6 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -253,6 +253,27 @@ same tree without leaving the process. command that says so and ends `exit 127`. `echo Tty > pane//ctl` makes an interactive terminal in that pane's directory. +A terminal can be bound as a language's REPL: `Repl python` in its tag or +on its `ctl` (the language names are the syntax table's; `Repl -` unbinds, +`Repl` bare says the binding, `Repl python` again changes nothing). Its tag +shows its id, `python-a`, `python-b` for the next, a freed letter reused. +Then an exec made by a gesture on the body of a file in that language -- a +middle click, the execute key, on a selection or a single word, even `make` +in a comment -- or on the REPL's own body is typed into the REPL instead of +run: bracketed paste when its program asked for it (DECSET 2004), else line +by line, where a blank line inside a Python block ends the block (said +once), then Enter. The message row says `→ python-a` in the tag's name tint +and the log `send `. With several REPLs bound for the +language the pane asks which, on its notice band, one key answering and Esc +sending nowhere; nothing is remembered. A command pane whose command is done +is no REPL any more. Still commands, whatever is bound: a word in a tag (so +the tag is how to run `make` from that file), `Exec ` 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`. The REPL gets the text wherever it is -- at a `pdb` or `input()` +prompt too. Bindings are not dumped, so a Restore leaves none. + The root's pair clicks at the active pane and `/pane//look` and `/pane//exec` at that pane. Blank lines are skipped, and every other line is checked before any of them runs, so a control character fails the whole diff --git a/docs/tags.md b/docs/tags.md index f1d7794d..f1e4a420 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -11,7 +11,11 @@ with the tag's name colour: `Tty+arg` is one word a tag can hold for `Tty arg`, so clicking it opens another terminal on that shell, as `Tty fish` would. Only a builtin that declares `plus_arg` reads a `+` so; `Tty` is the one, since other arguments (a dump's name) may contain `+`. A command pane's tag reads ` () running` or `exit N` -and offers `Kill`. +and offers `Kill`. `Repl python` in a terminal's tag binds it as Python's +REPL, its id, `python-a`, beside the Tty word: a middle click or the execute +key on a `.py` body then types the text into it rather than running it, and +several bound ask which. The tag's own words, `Exec ` by name and a +command word @`cmd` still run as commands (docs/fs.md). Pane and column command text leave a small gap after their aligned drag grips. GUI pane mode symbols are centered by their visible ink; changing the font or its size refreshes the cached measurements. diff --git a/src/File.zig b/src/File.zig index af538385..8ba58fbc 100644 --- a/src/File.zig +++ b/src/File.zig @@ -1130,7 +1130,7 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod const goff = pane.scroll(); const gcur = Terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; - const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side); + const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) const body_y = p.bodyTop(pane, r); diff --git a/src/Messages.zig b/src/Messages.zig index 00352afe..f8178629 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -180,6 +180,7 @@ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void { pane.pushOlderMessage(); if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot(); pane.msg_announcement = false; + pane.msg_sent = false; pane.msg_status = !stack; const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving; for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c; diff --git a/src/body_layer.zig b/src/body_layer.zig index c468bd5f..c8e229c4 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -479,7 +479,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) - if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side)) { + if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { if (pane.body.mode != .tty) { const cur = panes.Terminal.gridCursor(pane); const goff = panes.Terminal.gridOffset(pane); @@ -797,12 +797,12 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { .y = first + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg, page, fade)); p.surface.tag_layers[layer_index].slide = motion.slide; p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue; const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) continue; diff --git a/src/builtins.zig b/src/builtins.zig index 5273cf37..be0956e0 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -852,6 +852,61 @@ pub const Tty9p = struct { /// Fold the active pane's column into the one on its right, keeping its panes. /// The horizontal mirror of the vertical stacking `New` does. +/// Binds this terminal as a language's REPL, by the language's name as the +/// syntax table has it: an exec made on the body of a file in that +/// language -- a middle click, the execute key, a single word like `ls` +/// as much as a selection -- and one made on this terminal's own body, is +/// typed into it (bracketed paste if its program asked for that, else line +/// by line, then Enter) instead of run. `Repl python` binds (again, it is +/// already); `Repl -` unbinds; `Repl` bare says what it is bound as. With +/// several bound for a language, the exec asks which. +/// Still commands, whatever is bound: a word in a tag, `Exec ` 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. +pub const Repl = struct { + pub const takes_arg = true; + pub fn run(c: Ctx) void { + const pane = c.pane; + if (!pane.isTerminal()) return c.p.reportFailure(c.id, "Repl: only a terminal is a REPL"); + const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + var buf: [320]u8 = undefined; + var idbuf: [48]u8 = undefined; + if (arg.len == 0) { + if (pane.repl == null) return c.p.setMessage(c.id, "Repl: not bound"); + return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + } + if (std.mem.eql(u8, arg, "-")) { + pane.repl = null; + return c.p.setMessage(c.id, "Repl: unbound"); + } + const grammars = @import("grammar_manifest.zig").all; + const lang: u8 = for (grammars, 0..) |g, i| { + if (g.exts.len > 0 and std.mem.eql(u8, g.name, arg)) break @intCast(i); + } else { + var w = std.Io.Writer.fixed(&buf); + w.print("Repl: no language \"{s}\"; one of", .{arg[0..@min(arg.len, 32)]}) catch {}; + for (grammars) |g| if (g.exts.len > 0) w.print(" {s}", .{g.name}) catch break; + return c.p.reportFailure(c.id, w.buffered()); + }; + if (pane.repl) |r| if (r.lang == lang) + return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + // The lowest letter no other REPL of the language has: freed ones + // come back. + var letter: u8 = 'a'; + while (letter <= 'z') : (letter += 1) { + const taken = for (c.p.panes) |slot| { + const other = slot orelse continue; + if (other != pane) if (other.repl) |r| if (r.lang == lang and r.letter == letter) break true; + } else false; + if (!taken) break; + } + if (letter > 'z') return c.p.reportFailure(c.id, "Repl: every letter of the language is taken"); + pane.repl = .{ .lang = lang, .letter = letter }; + pane.repl_warned = false; + c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl"); + } +}; + pub const Joincol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { diff --git a/src/config.zig b/src/config.zig index 1a505334..9ad9cc7e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -48,6 +48,7 @@ pub const leader_path = paths: { .Mode = null, .Togglettymode = null, .Collapse = null, + .Repl = null, .Kill = null, .Exit = null, .ClipYank = "y", diff --git a/src/edit.zig b/src/edit.zig index b2d71ed2..3cbbc28c 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -87,7 +87,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; - if (pane.prompt != .none and pane.prompt != .del_side) { + if (pane.prompt != .none and pane.prompt != .del_side and pane.prompt != .repl_choice) { // an answer is one line: a newline in it would only be submitting it const line = p.scratch.allocator().alloc(u8, bytes.len) catch return; var n: usize = 0; diff --git a/src/exec.zig b/src/exec.zig index 8b058a12..1b82d2a8 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -16,6 +16,7 @@ const colors = @import("colors.zig"); const themes = colors.themes; const builtins = @import("builtins.zig"); const config = @import("config.zig"); +const grammars = @import("grammar_manifest.zig").all; const pdf_enabled = panes.Pdf.enabled; const font_picker = pardes.font_picker; const hosted = pardes.hosted; @@ -369,6 +370,96 @@ pub fn replaceStillborn(p: *Pardes) void { if (was_active) p.active = new_id; } +// ---- REPLs ---- +// +// A terminal is bound as a language's REPL by `Repl python` (builtins.zig). +// An exec made by a gesture on the body of a file in that language -- or on +// the REPL's own body -- is sent to it as input rather than run: all of it, +// a word like `ls` too. The tag's words, `Exec` by name and a command word +// `@`...`` still run as commands. + +/// A file pane's language, as grammar_manifest names it, by its index. +pub fn langOf(pane: *const Pane) ?u8 { + const f = pane.file orelse return null; + for (grammars, 0..) |g, i| for (g.exts) |ext| { + if (std.mem.endsWith(u8, f.path, ext)) return @intCast(i); + }; + return null; +} + +/// A bound REPL whose program can still take input: a command pane's is +/// gone once its command is done. +pub fn replLive(pane: *const Pane) bool { + return pane.repl != null and pane.isTerminal() and !(pane.command != null and pane.command_done); +} + +/// `python-a`. +pub fn replId(buf: []u8, pane: *const Pane) []const u8 { + const r = pane.repl orelse return ""; + return std.fmt.bufPrint(buf, "{s}-{c}", .{ grammars[r.lang].name, r.letter }) catch ""; +} + +const Routed = union(enum) { none, sent: usize, asked }; + +/// Where an exec from `from`'s body goes when a REPL is bound for it: the +/// one there is, or a question when there are several. +fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed { + const src = p.panes[from] orelse return .none; + if (src.repl != null and replLive(src)) { + sendToRepl(p, from, from, text); + return .{ .sent = from }; + } + const lang = langOf(src) orelse return .none; + var bound: [26]usize = undefined; + var n: usize = 0; + for (p.panes, 0..) |slot, i| if (slot) |pane| if (pane.repl) |r| if (r.lang == lang and replLive(pane) and n < bound.len) { + bound[n] = i; + n += 1; + }; + if (n == 0) return .none; + if (n == 1) { + sendToRepl(p, from, bound[0], text); + return .{ .sent = bound[0] }; + } + // Several: asked on the pane's notice band, as Del asks, one key + // answering; nothing is remembered of the answer. + const owned = p.gpa.dupe(u8, text) catch return .none; + if (p.repl_ask) |old| p.gpa.free(old.text); + p.repl_ask = .{ .from = src.serial, .lang = lang, .text = owned }; + var w = std.Io.Writer.fixed(&src.question); + w.print("send to {s}:", .{grammars[lang].name}) catch {}; + for (bound[0..n]) |i| w.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {}; + w.writeAll(" (Esc: none)") catch {}; + src.question_len = @intCast(w.buffered().len); + src.prompt = .repl_choice; + p.active = from; + return .asked; +} + +/// Types `text` into the REPL in `to`, bracketed if its program asked for +/// that, then Enter; says so on `from`'s message row and in the log. +pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { + const dst = p.panes[to] orelse return; + const src = p.panes[from] orelse return; + var idbuf: [48]u8 = undefined; + const id = replId(&idbuf, dst); + edit.typeToTty(p, to, dst, text); + 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 std.mem.startsWith(u8, id, "python") and + std.mem.indexOfScalar(u8, text, '\n') != null) + { + dst.repl_warned = true; + var warn: [128]u8 = undefined; + p.setMessage(from, std.fmt.bufPrint(&warn, "Repl: {s} takes no bracketed paste; a blank line in a block ends it", .{id}) catch "Repl: no bracketed paste"); + } + var said: [64]u8 = undefined; + p.setMessage(from, std.fmt.bufPrint(&said, "\u{2192} {s}", .{id}) catch "sent"); + src.msg_sent = true; + var rec: [96]u8 = undefined; + pardes.ctlfs.events.notePath(p, "send", std.fmt.bufPrint(&rec, "{d} {d} {s}", .{ src.serial, dst.serial, id }) catch return); +} + /// The longest line a command pane runs. pub const command_max = 1024; @@ -381,6 +472,11 @@ const max_exec_depth = 8; /// 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; + // Made by a gesture on a body, it may be a bound REPL's; run by name + // (`Exec ...`, which runs through here again) or written in a command + // word, it never is. + const gesture = p.exec_from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null; + p.exec_from_body = false; const cmd = commandText(txt); if (cmd.len == 0) return null; if (p.exec_depth == 0) p.noteHaptic(.exec); @@ -388,6 +484,11 @@ 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; + if (gesture) switch (routeToRepl(p, id, cmd)) { + .none => {}, + .sent => |to| return to, + .asked => return null, + }; if (!takesCommandLine(p, id)) return runCommand(p, id, cmd); panes.Terminal.padOutputBelowEdits(p, id); panes.Terminal.noteCommand(pane, cmd); diff --git a/src/fs-help.txt b/src/fs-help.txt index 0e87f40e..79701628 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -41,5 +41,5 @@ Pitfalls, one each: A terminal's body is a history snapshot frozen per open; pty/data is the live stream. 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. + lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New. + Repl python on a terminal's ctl: a .py body's clicks go to it; tag words, Exec x, @`x` stay commands. diff --git a/src/mouse.zig b/src/mouse.zig index 0ec20df2..f222b5da 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -190,7 +190,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { else => false, }) for (p.panes) |slot| { const pane = slot orelse continue; - if (pane.prompt == .del_side) pane.prompt = .none; + if (pane.prompt == .del_side or pane.prompt == .repl_choice) pane.prompt = .none; }; if (m.button == .back or m.button == .forward) { if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1); @@ -719,6 +719,9 @@ fn dispatchPointerBuiltin( // nothing, as acme's look3 and execute return on an empty expansion. const operand = text orelse return; if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return; + // A middle click on a body is the exec a bound REPL may take. + p.exec_from_body = cmd == config.exec_cmd and gesture != null and !gesture.?.on_tag; + defer p.exec_from_body = false; exec.runBuiltin(p, cmd, id, "", exec.withArg(p, operand, arg)); } diff --git a/src/panes.zig b/src/panes.zig index 2f8b6b56..9234912f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -72,6 +72,10 @@ pub const Pane = struct { /// Del from the keyboard with open panes both above and below: which /// one takes the rows. Nothing is typed; one key answers. del_side, + /// An exec from a file's body with several REPLs bound for its + /// language: which one takes it (exec.zig routeToRepl). One key + /// answers, the question being in `question`. + repl_choice, /// A new name for the file, drafted in place of the path on the /// tagline after a click there; Enter commits it, as a buffer name /// and never a disk rename. @@ -116,6 +120,18 @@ pub const Pane = struct { /// (Pardes.acknowledgeShell); empty until the host says which it ran. shell_tail: [96]u8 = undefined, shell_tail_len: u8 = 0, + /// A terminal bound as a language's REPL (`Repl python`): an exec from + /// the body of a file in that language is sent to it (exec.zig + /// routeToRepl). `letter` makes its id, `python-a`, with the language's + /// name from grammar_manifest. + repl: ?struct { lang: u8, letter: u8 } = null, + /// It has been told that its program takes no bracketed paste. + repl_warned: bool = false, + /// A question with its answers in it (`repl_choice`). + question: [96]u8 = undefined, + question_len: u8 = 0, + /// The newest message says where an exec was sent: it is tinted. + msg_sent: bool = false, /// 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). @@ -360,6 +376,7 @@ pub const Pane = struct { return switch (p.prompt) { .none, .name => null, .del_side => config.del_question, + .repl_choice => p.question[0..p.question_len], else => p.input.own orelse "", }; } diff --git a/src/pardes.zig b/src/pardes.zig index 5ba95be3..9821839f 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1391,6 +1391,100 @@ test "a spawn is for the pane it was made for, not whatever took the slot since" try std.testing.expectEqualStrings("second", Count.command[0.."second".len]); } +test "a REPL bound for a language takes the exec made on a body in it; tags, Exec by name and command words still run" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const file = try p.setTestFile("print(1)\nls\n"); + gpa.free(file.file.?.path); + file.file.?.path = try gpa.dupe(u8, "/x.py"); + const Sent = struct { + var to: [MAX_PANES][128]u8 = undefined; + var len: [MAX_PANES]usize = @splat(0); + var spawned: bool = false; + fn drain(pp: *Pardes) void { + len = @splat(0); + spawned = false; + while (pp.nextEffect()) |effect| switch (effect) { + .write => |w| { + @memcpy(to[w.pane][len[w.pane]..][0..w.bytes.slice().len], w.bytes.slice()); + len[w.pane] += w.bytes.slice().len; + }, + .spawn => spawned = true, + else => {}, + }; + } + fn said(pane: usize) []const u8 { + return to[pane][0..len[pane]]; + } + }; + // Only a terminal is a REPL; a language's name is the syntax table's. + try std.testing.expect(p.executeBuiltinLine(0, "Repl python")); + try std.testing.expect(file.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl pithon")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(std.mem.indexOf(u8, p.panes[1].?.msg[0..p.panes[1].?.msg_len], "python") != null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); // again: no change + var idbuf: [48]u8 = undefined; + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(std.mem.endsWith(u8, try tagline.tagPrefix(p, p.panes[1].?), " python-a")); + Sent.drain(p); + + // A click on the file's body, a single word: typed into the REPL. + p.exec_from_body = true; + try std.testing.expectEqual(@as(?usize, 1), exec.execute(p, 0, "ls")); + Sent.drain(p); + try std.testing.expectEqualStrings("ls\r", Sent.said(1)); + try std.testing.expect(!Sent.spawned); + try std.testing.expect(file.msg_sent); + try std.testing.expectEqualStrings("\u{2192} python-a", file.msg[0..file.msg_len]); + // Its own body too. + p.exec_from_body = true; + try std.testing.expectEqual(@as(?usize, 1), exec.execute(p, 1, "x = 1")); + Sent.drain(p); + try std.testing.expectEqualStrings("x = 1\r", Sent.said(1)); + + // Escapes: a tag's word, Exec by name, a command word. Each a command. + _ = exec.execute(p, 0, "ls"); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + try std.testing.expect(p.executeBuiltinLine(0, "Exec ls")); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + p.exec_from_body = true; + _ = exec.execute(p, 0, "@`ls`"); + Sent.drain(p); + try std.testing.expect(Sent.spawned and Sent.len[1] == 0); + + // Two bound: it asks, one key answers, Esc sends nowhere. + try std.testing.expect(p.executeBuiltinLine(2, "Repl python")); + try std.testing.expectEqualStrings("python-b", exec.replId(&idbuf, p.panes[2].?)); + Sent.drain(p); + p.exec_from_body = true; + try std.testing.expectEqual(@as(?usize, null), exec.execute(p, 0, "print(2)")); + try std.testing.expectEqual(Pane.Prompt.repl_choice, file.prompt); + try std.testing.expect(std.mem.indexOf(u8, file.promptText().?, "a b") != null); + p.update(.{ .key = .{ .cp = 'b' } }); + Sent.drain(p); + try std.testing.expectEqualStrings("print(2)\r", Sent.said(2)); + try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none); + p.exec_from_body = true; + _ = exec.execute(p, 0, "print(3)"); + p.update(.{ .key = .{ .cp = Key.escape } }); + Sent.drain(p); + try std.testing.expect(Sent.len[1] == 0 and Sent.len[2] == 0); + + // Unbound, its letter is free again; bare, it says what it is. + try std.testing.expect(p.executeBuiltinLine(1, "Repl -")); + try std.testing.expect(p.panes[1].?.repl == null); + try std.testing.expect(p.executeBuiltinLine(1, "Repl python")); + try std.testing.expectEqualStrings("python-a", exec.replId(&idbuf, p.panes[1].?)); + try std.testing.expect(p.executeBuiltinLine(2, "Repl")); + try std.testing.expectEqualStrings("Repl: python-b", p.panes[2].?.msg[0..p.panes[2].?.msg_len]); +} + test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -3929,6 +4023,13 @@ pub const Pardes = struct { chord_arg: ?[]u8 = null, /// how many `execute` calls are on the stack — see max_exec_depth exec_depth: u8 = 0, + /// The exec under way was made by a gesture on a pane's body -- a + /// middle click, the execute key -- and so may go to a REPL bound for + /// the text's language; one run by name, `Exec ...`, never does. + exec_from_body: bool = false, + /// An exec waiting on the REPL question (`Pane.Prompt.repl_choice`): + /// the pane it came from, the language, and the text, owned. + repl_ask: ?struct { from: u32, lang: u8, text: []u8 } = null, /// A key press is being handled: someone is at the keyboard to answer, /// so a builtin may ask rather than guess. Nothing else sets it -- not /// a click, a 9P write, a startup line or a pane's shell exiting. @@ -4138,6 +4239,7 @@ pub const Pardes = struct { pub fn deinit(p: *Pardes) void { look.cancelLookHover(p); + if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); for (&p.panes) |*slot| if (slot.*) |pane| { p.teardownPane(pane); @@ -5015,6 +5117,18 @@ pub const Pardes = struct { // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { const asked = slot orelse continue; + if (asked.prompt == .repl_choice) { + asked.prompt = .none; + const ask = p.repl_ask orelse return; + p.repl_ask = null; + defer p.gpa.free(ask.text); + // Its letter sends it there; anything else takes it back. + if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return; + for (p.panes, 0..) |target, to| if (target) |repl| if (repl.repl) |r| + if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(repl)) + return exec.sendToRepl(p, id, to, ask.text); + return; + } if (asked.prompt != .del_side) continue; asked.prompt = .none; if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k"); @@ -5126,7 +5240,7 @@ pub const Pardes = struct { look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| normal.applySelRegex(p, pane, answered, "", false), - .none, .del_side, .name => unreachable, + .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); if (target) |answered| { @@ -5164,6 +5278,9 @@ pub const Pardes = struct { return; } edit.pinPaneCursor(p, &pane.body); + // The execute key on a body is the exec a bound REPL may take. + p.exec_from_body = cmd == config.exec_cmd; + defer p.exec_from_body = false; const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active; if (explicit) { @@ -5999,12 +6116,16 @@ pub const Pardes = struct { // A message coming or going blends toward the page it sits // on; a grid cannot move it by less than a row. var style = msg_style; + // Where an exec was sent to a REPL reads in the tag's name + // tint, set apart from what the editor says. + const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg; + if (kind == .message and pane.msg_sent) style.fg = .{ .rgb = ink }; if (Messages.noticeLife(pane, i)) |life| { // No page colour to fade to (a terminal's own default): // the text dissolves into its chip instead. const alpha = Messages.messageMotion(p, life).alpha; const page = th.bg orelse chrome.tag_bg; - style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; + style.fg = .{ .rgb = Messages.blendRgb(ink, page, alpha) }; style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; } s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); @@ -6014,7 +6135,7 @@ pub const Pardes = struct { // the detached wire will encode -- which drops every frame for // as long as the prompt is up. const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + if (kind != .prompt or id != p.active or pane.prompt == .del_side or pane.prompt == .repl_choice) continue; // The cursor follows the text to wherever it landed; a caret // in the part a narrow band dropped has nowhere to be. const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); diff --git a/src/tagline.zig b/src/tagline.zig index 8c1efa7b..14bf5677 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -7,6 +7,7 @@ const modal = @import("modal.zig"); const ctlfs = @import("ninep/tree.zig"); const panes = @import("panes.zig"); const edit = @import("edit.zig"); +const exec = @import("exec.zig"); const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const dump = @import("dump.zig"); @@ -114,9 +115,17 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { 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 }); + var idbuf: [48]u8 = undefined; + const id = exec.replId(&idbuf, pane); + const gap = if (id.len > 0) " " else ""; + if (!pane.command_done) return std.fmt.allocPrint(arena, "{s} ({s}{s}) running{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); + if (pane.command_status) |n| return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit {d}{s}{s}", .{ pane.cwdSlice(), shown, more, n, gap, id }); + return std.fmt.allocPrint(arena, "{s} ({s}{s}) exit ?{s}{s}", .{ pane.cwdSlice(), shown, more, gap, id }); + } + // A bound REPL says what it is bound as, beside its Tty word. + if (pane.repl != null) { + var idbuf: [48]u8 = undefined; + return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) }); } if (pane.file) |f| { const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 9a58502f..84de680f 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -26,6 +26,7 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter -- cgit v1.3