summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--e2e_harness.zig16
-rw-r--r--main.zig77
-rw-r--r--tests.zig19
-rw-r--r--tutor.txt161
4 files changed, 194 insertions, 79 deletions
diff --git a/e2e_harness.zig b/e2e_harness.zig
index 8b795705..cd4ffd63 100644
--- a/e2e_harness.zig
+++ b/e2e_harness.zig
@@ -40,6 +40,10 @@ pub const Harness = struct {
/// when true, print the captured screen state after each pump/waitFor and on
/// every assertion, so live test runs can be inspected (zig build test -Dtrace).
trace: bool = false,
+ /// every raw byte the app has emitted (accumulated in pump). Lets tests assert
+ /// on control sequences the emulator consumes and never renders (e.g. OSC 52
+ /// clipboard writes). gpa-owned; freed in deinit.
+ raw: std.ArrayList(u8) = .empty,
pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness {
return initArgs(gpa, exe, rows, cols, null);
@@ -81,6 +85,7 @@ pub const Harness = struct {
posix.kill(self.pid, posix.SIG.KILL) catch {};
_ = linux.close(self.master);
self.stream.deinit();
+ self.raw.deinit(self.gpa);
self.term.deinit(self.gpa);
self.gpa.destroy(self.term);
}
@@ -110,6 +115,7 @@ pub const Harness = struct {
if ((fds[0].revents & posix.POLL.IN) != 0) {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
+ self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
self.stream.nextSlice(buf[0..n]);
}
}
@@ -159,6 +165,7 @@ pub const Harness = struct {
if ((fds[0].revents & posix.POLL.IN) != 0) {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
+ self.raw.appendSlice(self.gpa, buf[0..n]) catch {};
self.stream.nextSlice(buf[0..n]);
}
const text = try self.screenText();
@@ -234,6 +241,15 @@ pub const Harness = struct {
return error.ExpectFailed;
}
}
+
+ /// Assert a byte sequence appears anywhere in the app's raw output so far.
+ /// For control sequences the emulator swallows and never renders (OSC 52 etc.).
+ pub fn expectRawContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (std.mem.indexOf(u8, self.raw.items, needle) == null) {
+ std.debug.print("E2E FAIL: {s}\n(raw output has no {any})\n", .{ msg, needle });
+ return error.ExpectFailed;
+ }
+ }
};
/// Write a file (helper for test setup, raw linux syscalls).
diff --git a/main.zig b/main.zig
index 4c82d862..3bb0cfda 100644
--- a/main.zig
+++ b/main.zig
@@ -78,12 +78,16 @@ pub const Command = struct {
pty_eof: usize,
winsize: vaxis.Winsize,
mouse: vaxis.Mouse,
+ paste: []const u8, // OSC 52 system-clipboard read reply (the loop decodes it)
} = .nop;
};
// Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style
-// `y` yanks the (line) selection into here; `p` pastes it after the cursor.
+// `y` yanks the (line) selection into here; `p` pastes the system clipboard.
+// A yank also mirrors out to the system clipboard (OSC 52): setYank raises
+// yank_dirty and the main loop emits the copy where the tty writer is in scope.
var yank_reg: ?[]u8 = null;
+var yank_dirty: bool = false;
fn freeYank(gpa: std.mem.Allocator) void {
if (yank_reg) |y| gpa.free(y);
yank_reg = null;
@@ -91,6 +95,7 @@ fn freeYank(gpa: std.mem.Allocator) void {
fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
freeYank(gpa);
yank_reg = gpa.dupe(u8, text) catch null;
+ yank_dirty = true;
}
const Loop = vaxis.Loop(@TypeOf(Command.value));
@@ -1214,8 +1219,11 @@ const PaneLines = struct {
body: ?[]u8, // terminal only: bodyText temp; arena-owned, freed by caller
};
-// The lines the cursor moves over. File: all content lines (row0 = 0). Terminal:
-// the viewport rows (row0 = scroll offset). `alloc` is per-frame scratch.
+// The lines the cursor moves over, always with row0 = 0 (absolute rows). File: all
+// content lines. Terminal: the WHOLE history+active grid, not just the viewport, so
+// h/j/k/l/w/b/e/gg/G and the page motions ride the scrollback and ensureCursorVisible
+// scrolls the viewport to follow — exactly like a file pane. `alloc` is per-frame
+// scratch.
fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines {
if (t.file) |f| {
var ls: std.ArrayList([]const u8) = .empty;
@@ -1223,11 +1231,29 @@ fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines {
while (it.next()) |ln| try ls.append(alloc, ln);
return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = null };
}
- const body = try bodyText(alloc, t);
+ // Same prompt-hide + edit-splice transform as bodyText, but over every screen row
+ // (dump .screen, not .viewport) so the visible window still lines up with what's
+ // rendered while off-screen scrollback becomes navigable. paneCursorLines only runs
+ // in normal/insert (never tty), so prompt rows are always hidden here.
+ // ponytail: O(scrollback) dump+scan per keystroke — fine for normal sessions; window
+ // it around the viewport if a multi-MB scrollback ever makes navigation lag.
+ const full = try t.term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(full);
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(alloc);
+ var it = std.mem.splitAny(u8, full, "\n");
+ var pit = t.term.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |raw| : (i += 1) {
+ if (i > 0) try out.append(alloc, '\n');
+ const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
+ try spliceRow(alloc, &out, t, @intCast(i), if (is_prompt) "" else raw);
+ }
+ const body = try out.toOwnedSlice(alloc);
var ls: std.ArrayList([]const u8) = .empty;
- var it = std.mem.splitAny(u8, body, "\n");
- while (it.next()) |ln| try ls.append(alloc, ln);
- return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = paneScroll(t), .body = body };
+ var lit = std.mem.splitScalar(u8, body, '\n');
+ while (lit.next()) |ln| try ls.append(alloc, ln);
+ return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = body };
}
fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void {
@@ -1969,7 +1995,8 @@ fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key
if (key.matches('d', .{})) return normalDelete(gpa, talloc, t);
if (key.matches('c', .{})) return normalChange(gpa, talloc, t);
if (key.matches('y', .{})) return normalYank(gpa, talloc, t);
- if (key.matches('p', .{})) return normalPaste(gpa, t);
+ // `p` is intercepted in the main loop: it pastes the SYSTEM clipboard, which
+ // requires an OSC 52 round-trip (async .paste event), so it can't be done here.
if (key.matches('u', .{})) return doUndo(gpa, t);
if (key.matches('U', .{})) return doRedo(gpa, t);
}
@@ -2416,7 +2443,10 @@ pub fn main(init: std.process.Init) !void {
var tty_buf: [0x10000]u8 = undefined;
var tty = try vaxis.Tty.init(io, &tty_buf);
- var vx = try vaxis.init(io, gpa, init.environ_map, .{});
+ // system_clipboard_allocator lets the loop's reader thread decode OSC 52 paste
+ // replies (delivered as .paste events). gpa is already used cross-thread by the
+ // pty readers, so it's safe here too.
+ var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
defer vx.deinit(gpa, tty.writer());
try vx.setMouseMode(tty.writer(), true);
@@ -2811,6 +2841,14 @@ pub fn main(init: std.process.Init) !void {
break :kpress;
}
+ // `p` (normal mode) pastes the SYSTEM clipboard: request it now;
+ // the host replies with an OSC 52 sequence the loop surfaces as a
+ // .paste event, which inserts the text at the cursor.
+ if (at.mode == .normal and key.matches('p', .{})) {
+ vx.requestSystemClipboard(tty.writer()) catch {};
+ break :kpress;
+ }
+
// per-mode dispatch. Esc: insert -> normal (normal Esc is a
// no-op, like helix). Ctrl-b toggles raw tty on terminals (above).
// tty forwards every key to the pty.
@@ -2858,10 +2896,31 @@ pub fn main(init: std.process.Init) !void {
screen_h = ws.rows;
try vx.resize(gpa, tty.writer(), ws);
},
+ .paste => |text| {
+ // OSC 52 clipboard reply to our `p` request: load it into the yank
+ // register and paste it at the active pane's cursor, reusing the
+ // normal paste path. The register came FROM the clipboard, so don't
+ // echo it straight back out.
+ defer gpa.free(text);
+ if (terms[active]) |t| {
+ setYank(gpa, text);
+ yank_dirty = false;
+ normalPaste(gpa, t);
+ }
+ },
.quit => break,
else => {},
}
+ // a fresh yank (y/d/c) mirrors out to the system clipboard via OSC 52. Emit
+ // here where the tty writer + vx are in scope; the vx.render() below flushes.
+ // ponytail: OSC 52 has a per-terminal size cap, so a huge yank may be
+ // truncated by the host — the in-app register still holds the full text.
+ if (yank_dirty) {
+ yank_dirty = false;
+ if (yank_reg) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
+ }
+
// recompute geometry from the (possibly mutated) layout, then push any
// grid-size change to each emulator + pty. Weights only change on a drag
// *release*, so mid-drag the rects are unchanged and nothing resizes.
diff --git a/tests.zig b/tests.zig
index 1af5625d..1b4d6c24 100644
--- a/tests.zig
+++ b/tests.zig
@@ -210,6 +210,25 @@ pub fn main(init: std.process.Init) !void {
try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b)
try hs.pump(400);
+ // 6c. NORMAL-MODE MOTION SCROLLS THE TTY like a file pane: the pane is at the
+ // bottom (ZZFOLLOW visible). `gg` rides the cursor to the top of the
+ // scrollback and the viewport follows, so the bottom marker scrolls off;
+ // `G` rides it back down and the marker returns.
+ try hs.send("gg");
+ try hs.pump(500);
+ try hs.expectNotContains("ZZFOLLOW", "normal-mode gg did not scroll the tty up off the bottom");
+ try hs.send("G");
+ try hs.pump(500);
+ try hs.expectContains("ZZFOLLOW", "normal-mode G did not scroll the tty back to the bottom");
+
+ // 6d. YANK MIRRORS TO THE SYSTEM CLIPBOARD (OSC 52): select the line with `x`
+ // and yank with `y`; the app writes an OSC 52 clipboard-copy to the host.
+ // (The paste direction needs a host that answers OSC 52 reads, which this
+ // emulator can't, so it isn't covered here.)
+ try hs.send("xy");
+ try hs.pump(400);
+ try hs.expectRawContains("\x1b]52;c;", "yank did not emit an OSC 52 system-clipboard copy");
+
// 7. Right-click a directory opens (acme "look") a terminal there and ls's
// it. Make a dir with a unique entry, then `clear` and echo its path so the
// path lands as contiguous output on body row 0. Right-click (no drag) on
diff --git a/tutor.txt b/tutor.txt
index 1bdb9a9a..c2982fb7 100644
--- a/tutor.txt
+++ b/tutor.txt
@@ -24,8 +24,8 @@
This tutor is in THREE parts, ordered by what's most different from
editors you may know:
- PART 1 — the MOUSE. Acme's chording; nothing like Vim/Helix.
- PART 2 — the TTY. A terminal is just a pane; Enter positions you.
+ PART 1 — the MOUSE. Acme's three buttons; nothing like Vim/Helix.
+ PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it.
PART 3 — the KEYS. Helix-style modal (with the Pardes differences).
PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes
@@ -42,41 +42,49 @@
= PART 1 — THE MOUSE (acme chording; the most different part) =
=================================================================
- Acme's central idea: the THREE mouse buttons are a chording language
- over text. Pardes inherits this directly. (Vim and Helix have almost
- none of this — their mouse is for selection/scroll only.)
+ Acme's central idea: the THREE mouse buttons each ACT on text, and the
+ keyboard mirrors them. Pardes inherits this. (Vim and Helix have almost
+ none of it — their mouse is selection/scroll only.)
- LEFT (button 1) select text; a no-drag click also FOCUSES the
- pane and PINS the modal cursor where you click
- (so you can then edit there with the keyboard).
- MIDDLE (button 2) "execute" the selected text. Selecting a builtin
- name runs it; anything else is SENT to the shell
- the pane runs (if any). This is how you run a
- command you typed in text mode.
- RIGHT (button 3) "look" — resolve the word under the cursor as a
- path and open it: a directory opens/focuses a
- terminal there and ls's it; a file opens a file
- pane scrolled to a ":line" suffix if present.
+ ┌─────────┬─────────┬─────────┐
+ │ L │ M │ R │
+ │ (1) │ (2) │ (3) │
+ ├─────────┴─────────┴─────────┤
+ │ │
+ │ ( o ) │ the wheel scrolls the pane
+ │ │
+ └─────────────────────────────┘
- CHORDING (the acme magic): hold one button, press another. The
- classic is select-then-execute:
- - drag with LEFT to select a shell command you've typed
- - while STILL holding LEFT, press MIDDLE -> it runs
- - release both
- You can also select with LEFT then press RIGHT to "look" the selected
- word up as a file. Two-button chords = select-and-act in one gesture.
+ L select a plain click also FOCUSES the pane and PINS the modal
+ cursor where you click, so you can edit there next.
+ M execute run the selected text: a builtin name runs it, anything
+ else is SENT to the shell the pane runs. This is how you
+ run a command you typed in a text mode.
+ R look open the word under the cursor as a path: a directory
+ opens/focuses a terminal there and ls's it; a file opens
+ a file pane (scrolled to a ":line" suffix if present).
- THE TAG: every pane has a top bar (the "tag") — the pane's directory
- followed by the builtin command names (Newcol Delcol Del Tutor). It's just
- text: middle-click "Del" to close the window, "Newcol" to make a new
- column, "Delcol" to close the column, "Tutor" to spawn this tutor.
+ SELECT-THEN-ACT: there is no held-button chord. Instead:
+ - MIDDLE-drag over text selects AND runs it on release (one gesture);
+ a no-drag middle click auto-expands to the word under the cursor.
+ - or select with the keyboard (`v` chars / `x` lines), then press
+ Tab to execute or Enter to look — the acme chords on the keyboard.
+
+ THE TAG: each pane has a one-line tag: its MODE (nm/in/sy) + directory
+ or file path + the builtins "Del Delcol". Middle-click "Del" to close
+ the window, "Delcol" to close its column. A SEPARATE bar across the top
+ of the screen holds the window-agnostic builtins:
+ Kill Newcol Tutor Debug Colors NextColor
+ Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor,
+ "Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the
+ syntax/ansi recolor, and the theme.
Right-click a directory in any body to open a terminal there.
- LAYOUT: columns split horizontally, windows stack in each column.
+ LAYOUT: columns split the screen; windows stack within a column.
- drag the VERTICAL gap between columns to resize
- drag the HORIZONTAL gap between stacked windows to resize
- - drag the red box (top-left of a pane) to MOVE a window between
- columns / reorder it
+ - drag the accent box (top-left of a pane; indigo-purple, brighter
+ when the pane is focused) to MOVE a window between columns / reorder
- the scrollbar is the gutter below the box: left-click scrolls UP
to that point, right-click scrolls DOWN
@@ -96,31 +104,34 @@
prompt model rather than layering an editor cursor on top.
On a terminal pane:
- NORMAL (nm) shows the REAL shell — prompts and typed input visible.
- You navigate the screen with the same h/j/k/l/w/b/e
- as a file pane.
- INSERT (in) hides the prompt rows — a clean compose surface
- (acme-style) where you type a command to run.
- TTY (sy) keys go straight to the shell as terminal input.
+ NORMAL (nm) prompt rows are HIDDEN — a clean acme-style page. You
+ navigate it with the same h/j/k/l/w/b/e as a file.
+ INSERT (in) same clean page; keys type an insertion overlay (the
+ command you're composing). Prompts still hidden.
+ TTY (sy) the REAL shell — prompts + typed input shown, and keys
+ go straight to the pty as terminal input.
+
+ ESC: insert -> normal. (Esc never reaches tty.)
+ Ctrl-b: toggles TTY on a terminal — the ONLY way in or out. Entering
+ is tty-native: if the shell is at a prompt and your modal
+ cursor sits on the input line, it first moves the shell's REAL
+ cursor to that spot —
+ - empty input -> shell cursor at the prompt START
+ - typed text -> shell cursor at/after the char you were on
+ via the shell's OSC 133 semantic prompt (ghostty promptClickMove;
+ our rc opts in with cl=line). The shell moves its own cursor with
+ arrow keys it understands — Pardes doesn't fake one on top.
- ESC: insert -> normal. (Never reaches tty.)
- ENTER: normal -> tty, AND moves the shell's REAL cursor to where
- your modal cursor was on the input line:
- - empty input -> shell cursor at the prompt START
- - typed text -> shell cursor at/after the char you
- navigated to (start-vs-end matters)
- This is tty-native: it reuses the shell's OSC 133 semantic
- prompt via ghostty's promptClickMove (our shell rc opts in
- with cl=line). The shell moves its own cursor with arrow keys
- it understands — Pardes doesn't fake a cursor on top.
+ ENTER / TAB (in normal) are NOT a way into tty — they're the acme mouse
+ chords on the keyboard: Enter = "look" (open the path under the cursor),
+ Tab = "execute". Both act on the selection if one is active.
- So the flow is: navigate the shell screen in normal, hit Enter exactly
- where you want to keep typing/editing, and you're IN the shell at that
+ So the flow is: navigate the hidden-prompt page in normal, hit Ctrl-b
+ where you want to keep typing, and you're IN the live shell at that
spot. No separate "terminal mode cursor" to reconcile.
- (Positioning needs a live shell + a pty; covered by the e2e suite,
- tests.zig step 4b: type ABCDEF, navigate to the F, Enter, type X ->
- ABCDEXF, proving the shell cursor moved mid-input.)
+ (The cursor positioning is enterTty's promptClickMove; it needs a live
+ shell at a prompt. Mouse/tty behavior is exercised by the e2e suite.)
=================================================================
@@ -130,11 +141,15 @@
Same core as Helix and Vim: a block cursor you move with h/j/k/l,
motions w/b/e, and you enter insert with i/a/o. The differences:
- - NO select mode / multi-cursor (Helix). Selection is LINE-first:
- `x` grows a line selection downward; d/c/y act on it.
- - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word
- you select its lines with x then d.
- - ESC never reaches tty. Enter does (on terminals).
+ - Selection is LINE-first: `x` grows a line selection downward.
+ There's also `v` for a CHARACTER range. No multi-cursor. d/c/y act
+ on whichever selection is active (else the current line).
+ - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word,
+ select it (`v` then motions, or `x` for whole lines) then `d`.
+ - ESC never reaches tty; it only does insert -> normal. Dropping a
+ terminal into the live shell is Ctrl-b (see Part 2).
+ - Enter / Tab in normal mirror the mouse: Enter = look (open the path
+ under the cursor), Tab = execute. `u` undo, `U` redo.
- Editing a file pane MUTATES real content; a terminal pane yanks
rendered text and pastes it as an insertion run (shell output
can't be deleted, only pasted text can).
@@ -347,9 +362,10 @@
= 3.6 VIEWPORT + PANES =
-----------------------------------------------------------------
- zt / zz / zb scroll so the cursor is at top/center/bottom
- Ctrl-d / Ctrl-u half-page down / up
- Ctrl-f / Ctrl-b full page down / up
+ zt / zz / zb scroll so the cursor is at top/center/bottom
+ Ctrl-d / Ctrl-u half-page down / up
+ Ctrl-f full page down. (Ctrl-b pages up on a file pane,
+ but on a terminal it's the tty toggle — see Part 2.)
Ctrl-w h/j/k/l focus the pane left/down/up/right
Alt-n new terminal below the active one
Alt-c move the active terminal into a fresh column
@@ -361,21 +377,26 @@
= SUMMARY =
=================================================================
- MOUSE (acme): L select/focus+pin M execute R look(open)
- chord L+M = select-and-run; tag builtins (Del, ...)
- TTY: terminal IS a pane; Enter from normal -> tty AT the
- cursor's spot (start if empty, mid-text otherwise)
- KEYS (helix): h j k l w b e 0 $ ^ gg G x d c y p i a I A o O
- zt zz zb Ctrl-d/u/f/b Ctrl-w hjkl Alt-n/c
+ MOUSE (acme): L select/focus+pin M execute R look(open)
+ select-then-act: middle-drag, or v/x then Tab
+ tag = mode + dir + "Del Delcol"; top bar = Kill Newcol
+ Tutor Debug Colors NextColor
+ TTY: a terminal IS a pane; Ctrl-b toggles the live shell,
+ landing its cursor where you navigated (prompt start if
+ the input is empty, mid-text otherwise)
+ KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O
+ u undo U redo Enter=look Tab=execute
+ zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c
- vs Helix: no select-mode, no multi-cursor; selection is LINE-first.
+ vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty.
vs both: a terminal is just a pane; the same keys edit text and
- navigate the shell screen, and Enter reuses the shell's
+ navigate the shell screen, and Ctrl-b reuses the shell's
own prompt to position you precisely.
- To spawn THIS tutor again from anywhere: middle-click "Tutor" in any
- pane's tag (it's a builtin, next to Newcol/Delcol/Del).
+ To spawn THIS tutor again from anywhere: middle-click "Tutor" in the
+ top bar (next to Kill / Newcol).
Quit the tutor: this is a file pane — `:q` isn't wired; close the
- window (middle-click "Del" in the tag) or Ctrl-c the app.
+ window (middle-click "Del" in its tag), "Kill" (top bar) to quit
+ everything, or Ctrl-c the app.