summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 11:43:52 -0300
committerGabriel Schneider <[email protected]>2026-06-30 12:55:48 -0300
commit651af1db938bcbaa039d14b65ff6bc184a3d91b4 (patch)
treea9d2580bddd813c276b89875b5c15d85b2039b41
parent8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (diff)
downloadpardes-651af1db938bcbaa039d14b65ff6bc184a3d91b4.tar.gz
pardes-651af1db938bcbaa039d14b65ff6bc184a3d91b4.zip
vibes cleaning slop
-rw-r--r--main.zig534
-rw-r--r--tests.zig164
2 files changed, 462 insertions, 236 deletions
diff --git a/main.zig b/main.zig
index 49413eec..7221c5eb 100644
--- a/main.zig
+++ b/main.zig
@@ -213,6 +213,28 @@ fn paneScrollBy(t: *Term, delta: i32) void {
}
}
+// Before running a composed command in a terminal, push the shell down so its
+// output lands BELOW the last row the user typed overlay text on. The shell owns
+// its cursor, so we move it the only way we can: emit newlines to the pty until
+// the prompt sits at max(last-edit row, the shell's current prompt row). The empty
+// prompts this creates are hidden in text mode (promptFlags), so the gap reads as
+// blank padding and the output flows right under the composed text. No-op unless
+// the shell is at a prompt (don't inject newlines into a running program).
+// ponytail: pad caps at the grid height — you can't click/type past the pane.
+fn padOutputBelowEdits(t: *Term) void {
+ if (t.file != null or t.nedits == 0) return;
+ if (!t.term.cursorIsAtPrompt()) return;
+ var max_row: i32 = std.math.minInt(i32);
+ for (t.edits[0..t.nedits]) |e| {
+ if (e.len > 0 and e.row > max_row) max_row = e.row;
+ }
+ if (max_row == std.math.minInt(i32)) return;
+ const cursor_abs: i32 = paneScroll(t) + @as(i32, @intCast(t.term.screens.active.cursor.y));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, t.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) termWrite(t, "\r");
+}
+
// The shell's current working directory (via /proc/<pid>/cwd). `buf` holds it.
// A raw linux syscall returns -errno; check the sign directly. (std.posix.errno
// is the LIBC convention here — `rc == -1` — and we link libc, so it misreads a
@@ -242,8 +264,11 @@ fn isDir(path: [*:0]const u8) bool {
// The window tag: the shell's cwd followed by the builtin command names. Plain,
// selectable text (acme-style). Caller frees with `alloc`.
fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
- // a file pane's tag is just its path (+ builtins); no tty mode/cwd.
- if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str });
+ // a file pane's tag: mode + path (+ builtins); no cwd. Files never reach tty.
+ if (t.file) |f| {
+ const fmode = if (t.mode == .insert) "in" else "nm";
+ return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ fmode, f.path, builtins_str });
+ }
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
// leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty).
@@ -518,6 +543,40 @@ fn evictLonePristineTty(l: *Layout, terms: *[MAX_TERMS]?*Term, io: std.Io, gpa:
if (active.* == tty_id) active.* = keep_id;
}
+// Find a terminal whose shell cwd is `dir`, else fork a fresh shell there and
+// stack it at the bottom of the rightmost column. Returns the terminal to drive,
+// or null if no slot is free / the fork failed. Backs middle-click "send" from a
+// file pane: the selected line runs in a shell in the file's dir. Does NOT change
+// focus — middle/execute keeps you where you were editing (right/look focuses).
+// ponytail: rightmost column; with a single column the tty stacks under the file.
+fn ttyForDir(
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ loop: *Loop,
+ terms: *[MAX_TERMS]?*Term,
+ layout: *Layout,
+ sw: u16,
+ sh: u16,
+ dir: []const u8,
+) ?*Term {
+ for (terms.*) |slot| if (slot) |tt| {
+ if (tt.file != null) continue;
+ var cb: [1024]u8 = undefined;
+ if (std.mem.eql(u8, termCwd(tt, &cb), dir)) return tt;
+ };
+ const free: usize = for (terms.*, 0..) |slot, i| {
+ if (slot == null) break i;
+ } else return null;
+ var dirbuf: [1024]u8 = undefined;
+ const dirz = std.fmt.bufPrintZ(&dirbuf, "{s}", .{dir}) catch return null;
+ const nt = forkTerm(gpa, sw, sh, dirz.ptr) catch return null;
+ terms[free] = nt;
+ startReader(io, gpa, loop, nt, free) catch {};
+ const rc = if (layout.ncol > 0) layout.ncol - 1 else 0;
+ layoutInsert(layout, rc, layout.col_n[rc], free);
+ return nt;
+}
+
// Tile the screen: columns by col_weight, then each column's windows by vweight.
// Last column/window absorbs rounding so the tiling always fills the screen.
// `out_*` written for every live term and live column.
@@ -1087,13 +1146,14 @@ fn insertRunText(t: *Term, text: []const u8) void {
// viewport z t / z z / z b (top/center/bottom)
// insert i a I A o O (at/append/line-start/line-end/below/above)
// select line x (extend down) delete d change c yank y paste p
-// mode Esc -> insert exits to normal; Enter -> tty (terminals only)
+// chord Enter -> acme look (open file/dir) Tab -> acme execute (run word)
+// mode Esc -> insert exits to normal; Ctrl-b -> raw tty (terminals)
// window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col)
// File panes mutate their content (real edits); terminals yank rendered text and
// paste it as an insertion run (shell output can't be deleted, so d/c there only
// clear pasted runs in the selection). Select mode (`v`, char ranges) is not yet
// wired; use `x` for line-oriented selection.
-// Enter tty mode from normal (terminals only). tty-native: if the shell is at a
+// Enter raw tty mode (the Ctrl-b toggle; terminals only). tty-native: if the shell is at a
// prompt and the modal cursor sits on the current input line, move the shell's
// REAL cursor there via ghostty's promptClickMove (sends left/right arrows the
// shell understands) — so dropping into tty lands where you navigated: at the
@@ -1129,9 +1189,6 @@ fn enterTty(t: *Term) void {
fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
pinCursor(t);
- // terminals: Enter drops into tty, positioning the shell cursor at the modal
- // cursor's spot on the input line (start if empty, end if typed text).
- if (t.file == null and key.matches(vaxis.Key.enter, .{})) return enterTty(t);
const pl = paneCursorLines(talloc, t) catch return;
defer freePaneLines(talloc, pl);
const cur = toModalCursor(t, pl);
@@ -1371,6 +1428,213 @@ fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_
if (best) |b| active.* = b;
}
+// Perform an acme button-2/3 chord with `txt` as the text, from pane `id`:
+// .right = "look" (open the file/dir under the word, peeling a :line suffix),
+// .middle = "execute" (a builtin, else run the text in a shell). Shared by the
+// mouse release and the normal-mode Enter (look) / Tab (execute) keys. Returns
+// true if the app should quit (the last pane was closed).
+fn actOnSelection(
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ loop: *Loop,
+ terms: *[MAX_TERMS]?*Term,
+ layout: *Layout,
+ rects: *const [MAX_TERMS]Rect,
+ active: *usize,
+ sw: u16,
+ sh: u16,
+ button: Button,
+ id: usize,
+ txt: []const u8,
+) bool {
+ const t = terms[id] orelse return false;
+ if (button == .right) {
+ // acme "look": resolve the selected word (peeling a :line suffix) relative
+ // to the window's dir. A directory opens/focuses a terminal there + ls; a
+ // file opens/focuses a file pane scrolled to :line.
+ const word_raw = std.mem.trim(u8, txt, " \t\r\n");
+ const pl = parsePathLine(word_raw);
+ const word = pl.path;
+ var cwdbuf: [1024]u8 = undefined;
+ var joinbuf: [2048]u8 = undefined;
+ var realbuf: [4096]u8 = undefined;
+ const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf);
+ const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/')
+ (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null)
+ else
+ (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null);
+ if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) {
+ const dir = std.mem.span(rp);
+ // focus an existing terminal on this dir
+ var found: ?usize = null;
+ for (terms, 0..) |*slot, i| if (slot.*) |tt| {
+ var cb: [1024]u8 = undefined;
+ if (std.mem.eql(u8, termCwd(tt, &cb), dir)) {
+ found = i;
+ break;
+ }
+ };
+ if (found) |fid| {
+ active.* = fid;
+ if (terms[fid]) |tt| termWrite(tt, "ls\r");
+ } else {
+ const slot: ?usize = for (terms.*, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (forkTerm(gpa, sw, sh, rp)) |nt| {
+ terms[free] = nt;
+ startReader(io, gpa, loop, nt, free) catch {};
+ const f = layoutFindTerm(layout, id).?;
+ layoutInsert(layout, f.col, f.idx + 1, free);
+ splitBelow(t, nt, rects[id].h);
+ active.* = free;
+ termWrite(nt, "ls\r");
+ } else |_| {}
+ }
+ }
+ } else {
+ const fp = std.mem.span(rp);
+ // focus an existing file pane for this path
+ var found: ?usize = null;
+ for (terms, 0..) |*slot, i| if (slot.*) |tt| {
+ if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) {
+ found = i;
+ break;
+ };
+ };
+ if (found) |fid| {
+ active.* = fid;
+ if (pl.line > 0) if (terms[fid]) |tt| if (tt.file) |*ff|
+ if (pl.line <= fileLineCount(ff.content)) {
+ ff.scroll = pl.line - 1;
+ };
+ } else {
+ const slot: ?usize = for (terms.*, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (openFileView(gpa, rp, pl.line, sw, sh)) |nt| {
+ terms[free] = nt;
+ // files live in the LEFT column (acme convention). Stack at its
+ // top with a fair share of the column height, then drop a
+ // leftover unused shell.
+ const lc = 0;
+ const ln = layout.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
+ vsum += p.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ layoutInsert(layout, lc, 0, free);
+ active.* = free;
+ evictLonePristineTty(layout, terms, io, gpa, lc, free, active);
+ } else |_| {}
+ }
+ }
+ };
+ return false;
+ }
+ switch (onSelect(button, txt)) {
+ .none => {},
+ .send => {
+ // terminal: run in itself. file pane: run in a shell sitting in the
+ // file's directory (found by cwd, else a fresh tty in the right column).
+ const cmd = std.mem.trim(u8, txt, " \t\r\n");
+ const dst: ?*Term = if (t.file) |f|
+ ttyForDir(io, gpa, loop, terms, layout, sw, sh, std.fs.path.dirname(f.path) orelse "/")
+ else
+ t;
+ if (dst) |dt| {
+ padOutputBelowEdits(dt);
+ termWrite(dt, cmd);
+ termWrite(dt, "\r");
+ }
+ },
+ .newcol => {
+ // new terminal in a fresh column; ls its directory.
+ const slot: ?usize = for (terms.*, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| if (layout.ncol < MAX_COLS) {
+ if (forkTerm(gpa, sw, sh, null)) |nt| {
+ terms[free] = nt;
+ startReader(io, gpa, loop, nt, free) catch {};
+ layoutAppendColumn(layout, free);
+ active.* = free;
+ termWrite(nt, "ls\r");
+ } else |_| {}
+ };
+ },
+ .del => {
+ // close just this window
+ absorbVWeight(layout, terms, id);
+ layoutRemove(layout, id);
+ t.deinit(io, gpa);
+ gpa.destroy(t);
+ terms[id] = null;
+ if (active.* == id) active.* = for (terms.*, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else return true;
+ },
+ .delcol => {
+ // close every terminal in this one's column
+ if (layoutFindTerm(layout, id)) |f| {
+ var ids: [MAX_TERMS]usize = undefined;
+ const nids = layout.col_n[f.col];
+ for (0..nids) |k| ids[k] = layout.col_terms[f.col][k];
+ for (ids[0..nids]) |tid| {
+ if (terms[tid]) |tt| {
+ layoutRemove(layout, tid);
+ tt.deinit(io, gpa);
+ gpa.destroy(tt);
+ terms[tid] = null;
+ }
+ }
+ if (terms[active.*] == null) active.* = for (terms.*, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else return true;
+ }
+ },
+ .tutor => {
+ // spawn the Tutor as a file pane in the LEFT column (acme convention for
+ // files), stacked at the top with a fair share of the column.
+ const slot: ?usize = for (terms.*, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (openTutorView(gpa, sw, sh)) |nt| {
+ terms[free] = nt;
+ const lc = 0;
+ const ln = layout.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
+ vsum += p.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ layoutInsert(layout, lc, 0, free);
+ active.* = free;
+ evictLonePristineTty(layout, terms, io, gpa, lc, free, active);
+ } else |_| {}
+ }
+ },
+ }
+ return false;
+}
+
+// The word under the modal cursor as a pane-local selection in paneText coords
+// (row 0 is the tag; file panes carry the line-number prefix). Feed it through
+// expandSel + selectionText to get the acme chord text for the Enter/Tab keys.
+fn cursorWordSel(t: *Term) Term.Sel {
+ const vrow = (t.cur_row - paneScroll(t)) + @as(i32, BOX_H);
+ const vcol = if (t.file != null) t.cur_col + @as(i32, PREFIX_W) else t.cur_col;
+ return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
+}
+
pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
@@ -1423,6 +1687,9 @@ pub fn main(init: std.process.Init) !void {
terms[0] = try forkTerm(gpa, screen_w, screen_h, null);
terms[1] = try forkTerm(gpa, screen_w, screen_h, null);
terms[2] = try forkTerm(gpa, screen_w, screen_h, null);
+ // a fresh shell greets you with its directory listing (the readers below pick
+ // up the output once they start).
+ for (terms[0..3]) |slot| if (slot) |t| termWrite(t, "ls\r");
layout.ncol = 2;
layout.col_n[0] = 2;
layout.col_terms[0][0] = 0;
@@ -1526,19 +1793,21 @@ pub fn main(init: std.process.Init) !void {
}
}
} else if (mouse.type == .press and (mouse.button == .middle or mouse.button == .right)) {
- // middle/right select text too (own state + color), text area only
+ // middle/right select text too (own state + color), text area only.
+ // Right focuses the pane (acme "look" lands you there); middle
+ // (execute) does NOT — you stay where you were editing.
const b: Button = if (mouse.button == .middle) .middle else .right;
if (hovered) |id| {
const r = rects[id];
if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
// gutter (scrollbar): right scrolls down, mirroring left's up
- active = id;
+ if (b == .right) active = id;
if (terms[id]) |t| {
const local: i32 = @as(i32, mrow) - @as(i32, r.y + BOX_H);
paneScrollBy(t, if (b == .right) local else -local);
}
} else if (mcol >= r.x + GUTTER) {
- active = id;
+ if (b == .right) active = id;
if (terms[id]) |t| {
const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
const v: i32 = @as(i32, mrow) - @as(i32, r.y);
@@ -1623,197 +1892,33 @@ pub fn main(init: std.process.Init) !void {
.select => |s| {
if (terms[s.id]) |t| {
t.sel[@intFromEnum(s.button)].state = .done;
- // a no-drag middle/right click expands to the word
- // under the cursor (acme button-2/3 behavior).
- if (s.button != .left) expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]);
- if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
- defer gpa.free(txt);
- if (s.button == .right) {
- // acme "look": resolve the selected word
- // (peeling a :line suffix) relative to the
- // window's dir. A directory opens/focuses a
- // terminal there + ls; a file opens/focuses a
- // file pane scrolled to :line.
- const word_raw = std.mem.trim(u8, txt, " \t\r\n");
- const pl = parsePathLine(word_raw);
- const word = pl.path;
- var cwdbuf: [1024]u8 = undefined;
- var joinbuf: [2048]u8 = undefined;
- var realbuf: [4096]u8 = undefined;
- const cwd = if (t.file) |f| (std.fs.path.dirname(f.path) orelse "/") else termCwd(t, &cwdbuf);
- const joined: ?[:0]u8 = if (word.len == 0) null else if (word[0] == '/')
- (std.fmt.bufPrintZ(&joinbuf, "{s}", .{word}) catch null)
- else
- (std.fmt.bufPrintZ(&joinbuf, "{s}/{s}", .{ cwd, word }) catch null);
- if (joined) |jz| if (realpath(jz.ptr, &realbuf)) |rp| if (isDir(rp)) {
- const dir = std.mem.span(rp);
- // focus an existing terminal on this dir
- var found: ?usize = null;
- for (&terms, 0..) |*slot, i| if (slot.*) |tt| {
- var cb: [1024]u8 = undefined;
- if (std.mem.eql(u8, termCwd(tt, &cb), dir)) {
- found = i;
- break;
- }
- };
- if (found) |id| {
- active = id;
- if (terms[id]) |tt| termWrite(tt, "ls\r");
- } else {
- const slot: ?usize = for (terms, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- if (forkTerm(gpa, screen_w, screen_h, rp)) |nt| {
- terms[free] = nt;
- startReader(io, gpa, &loop, nt, free) catch {};
- const f = layoutFindTerm(&layout, s.id).?;
- layoutInsert(&layout, f.col, f.idx + 1, free);
- splitBelow(t, nt, rects[s.id].h);
- active = free;
- termWrite(nt, "ls\r");
- } else |_| {}
- }
- }
- } else {
- const fp = std.mem.span(rp);
- // focus an existing file pane for this path
- var found: ?usize = null;
- for (&terms, 0..) |*slot, i| if (slot.*) |tt| {
- if (tt.file) |ff| if (std.mem.eql(u8, ff.path, fp)) {
- found = i;
- break;
- };
- };
- if (found) |id| {
- active = id;
- if (pl.line > 0) if (terms[id]) |tt| if (tt.file) |*ff|
- if (pl.line <= fileLineCount(ff.content)) {
- ff.scroll = pl.line - 1;
- };
- } else {
- const slot: ?usize = for (terms, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- if (openFileView(gpa, rp, pl.line, screen_w, screen_h)) |nt| {
- terms[free] = nt;
- // files live in the LEFT column (acme
- // convention). Stack at its top with a
- // fair share of the column height, then
- // drop a leftover unused shell.
- const lc = 0;
- const ln = layout.col_n[lc];
- if (ln > 0) {
- var vsum: f32 = 0;
- for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
- vsum += p.vweight;
- };
- nt.vweight = vsum / @as(f32, @floatFromInt(ln));
- }
- layoutInsert(&layout, lc, 0, free);
- active = free;
- evictLonePristineTty(&layout, &terms, io, gpa, lc, free, &active);
- } else |_| {}
- }
- }
- };
- } else switch (onSelect(s.button, txt)) {
- .none => {},
- .send => {
- // run the selection in the terminal it came from
- termWrite(t, std.mem.trim(u8, txt, " \t\r\n"));
- termWrite(t, "\r");
- },
- .newcol => {
- // new terminal in a fresh column
- const slot: ?usize = for (terms, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| if (layout.ncol < MAX_COLS) {
- if (forkTerm(gpa, screen_w, screen_h, null)) |nt| {
- terms[free] = nt;
- startReader(io, gpa, &loop, nt, free) catch {};
- layoutAppendColumn(&layout, free);
- active = free;
- } else |_| {}
- };
- },
- .del => {
- // close just this window
- absorbVWeight(&layout, &terms, s.id);
- layoutRemove(&layout, s.id);
- t.deinit(io, gpa);
- gpa.destroy(t);
- terms[s.id] = null;
- if (active == s.id) active = for (terms, 0..) |sl, i| {
- if (sl != null) break i;
- } else break;
- },
- .delcol => {
- // close every terminal in this one's column
- if (layoutFindTerm(&layout, s.id)) |f| {
- var ids: [MAX_TERMS]usize = undefined;
- const nids = layout.col_n[f.col];
- for (0..nids) |k| ids[k] = layout.col_terms[f.col][k];
- for (ids[0..nids]) |tid| {
- if (terms[tid]) |tt| {
- layoutRemove(&layout, tid);
- tt.deinit(io, gpa);
- gpa.destroy(tt);
- terms[tid] = null;
- }
- }
- if (terms[active] == null) active = for (terms, 0..) |sl, i| {
- if (sl != null) break i;
- } else break;
- }
- },
- .tutor => {
- // spawn the Tutor as a file pane in the LEFT
- // column (acme convention for files), stacked
- // at the top with a fair share of the column.
- const slot: ?usize = for (terms, 0..) |sl, i| {
- if (sl == null) break i;
- } else null;
- if (slot) |free| {
- if (openTutorView(gpa, screen_w, screen_h)) |nt| {
- terms[free] = nt;
- const lc = 0;
- const ln = layout.col_n[lc];
- if (ln > 0) {
- var vsum: f32 = 0;
- for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
- vsum += p.vweight;
- };
- nt.vweight = vsum / @as(f32, @floatFromInt(ln));
- }
- layoutInsert(&layout, lc, 0, free);
- active = free;
- evictLonePristineTty(&layout, &terms, io, gpa, lc, free, &active);
- } else |_| {}
- }
- },
- }
- } else |_| {}
if (s.button == .left) {
// left-click in the body enters NORMAL mode and
- // anchors the cursor at the selection end (a
- // visual column). The row is stored absolute
- // (visual + scroll off) so it rides the scrollback.
- // clicks on the tag (row < BOX_H) don't edit.
+ // anchors the cursor at the selection end. The row is
+ // stored absolute (visual + scroll off) so it rides
+ // the scrollback. tag clicks (row < BOX_H) don't edit.
const sl = t.sel[@intFromEnum(Button.left)];
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0) {
const off: i32 = paneScroll(t);
t.cur_row = body_vis + off;
- t.cur_col = sl.c1;
+ // file panes render a line-number prefix; cur_col
+ // is content-relative, so peel PREFIX_W off the click.
+ t.cur_col = if (t.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1;
t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
t.cur_pinned = true;
t.mode = .normal;
t.msel.active = false;
t.pending = 0;
}
+ } else {
+ // middle/right: acme execute/look. A no-drag click
+ // expands to the file-ish word under the cursor first.
+ expandSel(gpa, t, &t.sel[@intFromEnum(s.button)]);
+ if (selectionText(gpa, t, t.sel[@intFromEnum(s.button)])) |txt| {
+ defer gpa.free(txt);
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, s.button, s.id, txt)) break;
+ } else |_| {}
}
}
},
@@ -1857,6 +1962,7 @@ pub fn main(init: std.process.Init) !void {
const f = layoutFindTerm(&layout, active).?;
layoutInsert(&layout, f.col, f.idx + 1, slot);
active = slot;
+ termWrite(nt, "ls\r");
} else |_| {}
}
break :kpress;
@@ -1871,9 +1977,37 @@ pub fn main(init: std.process.Init) !void {
break :kpress;
}
+ // Ctrl-b: hidden toggle in/out of raw tty mode (terminals only).
+ // tty forwards every key straight to the pty, so it's deliberately
+ // off the normal editing path — this toggle is the only way in/out.
+ if (key.matches('b', .{ .ctrl = true })) {
+ if (at.file == null) {
+ if (at.mode == .tty) {
+ at.mode = .normal;
+ at.pending = 0;
+ } else enterTty(at);
+ }
+ break :kpress;
+ }
+
+ // Normal-mode Enter/Tab mirror the acme mouse chords on the word
+ // under the cursor: Enter = right-click "look" (open the file/dir),
+ // Tab = middle-click "execute" (run it / builtin).
+ if (at.mode == .normal and (key.matches(vaxis.Key.enter, .{}) or key.matches(vaxis.Key.tab, .{}))) {
+ pinCursor(at);
+ const btn: Button = if (key.matches(vaxis.Key.tab, .{})) .middle else .right;
+ var sel = cursorWordSel(at);
+ expandSel(gpa, at, &sel);
+ if (selectionText(gpa, at, sel)) |word| {
+ defer gpa.free(word);
+ if (actOnSelection(io, gpa, &loop, &terms, &layout, &rects, &active, screen_w, screen_h, btn, active, word)) break;
+ } else |_| {}
+ break :kpress;
+ }
+
// per-mode dispatch. Esc: insert -> normal (normal Esc is a
- // no-op, like helix). Enter from normal reaches tty on terminals
- // (handled in handleNormal). tty forwards every key to the pty.
+ // no-op, like helix). Ctrl-b toggles raw tty on terminals (above).
+ // tty forwards every key to the pty.
switch (at.mode) {
.normal => handleNormal(gpa, frame_arena.allocator(), at, key),
.insert => {
@@ -1889,7 +2023,15 @@ pub fn main(init: std.process.Init) !void {
},
.pty_read => |pr| {
defer gpa.free(pr.bytes);
- if (terms[pr.id]) |t| t.stream.nextSlice(pr.bytes);
+ if (terms[pr.id]) |t| {
+ t.stream.nextSlice(pr.bytes);
+ // follow new output to the bottom, so a command's result shows
+ // even in a pane you didn't focus. Exception: the pane you're
+ // actively navigating in a text mode — don't yank the cursor out
+ // from under you (it still tracks the bottom on its own there).
+ if (!(pr.id == active and t.mode != .tty))
+ t.term.screens.active.scroll(.active);
+ }
},
.pty_eof => |id| {
if (terms[id]) |t| {
@@ -2174,15 +2316,13 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
const body = try t.term.plainString(alloc);
defer alloc.free(body);
- // text mode hides shell prompt rows only in INSERT mode (the acme compose
- // surface: a clean blank line to type into). NORMAL mode shows the real
- // terminal — prompts + typed input visible — so the modal cursor can navigate
- // to the input line and Enter drops into tty at that spot (start vs end).
- // tty mode shows everything.
+ // text mode (normal AND insert) hides shell prompt rows: a clean acme-style
+ // page to navigate and type on. Only raw tty mode (the Ctrl-b toggle) shows
+ // the real terminal with its prompts + typed input.
var flagbuf: [512]bool = undefined;
const nrows: usize = @min(t.rows, flagbuf.len);
const flags = flagbuf[0..nrows];
- if (t.mode == .insert) promptFlags(t, flags) else @memset(flags, false);
+ if (t.mode != .tty) promptFlags(t, flags) else @memset(flags, false);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
// pad to the full grid height: plainString trims trailing blank rows, but we
diff --git a/tests.zig b/tests.zig
index c389b8cf..504dad4d 100644
--- a/tests.zig
+++ b/tests.zig
@@ -16,6 +16,16 @@ const config = @import("config");
// Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right.
// A drag is press at start + release at end (with different coords).
+// Row index (0-based) of the first screen line containing `needle`, or null.
+fn rowOf(text: []const u8, needle: []const u8) ?usize {
+ var it = std.mem.splitScalar(u8, text, '\n');
+ var i: usize = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (std.mem.indexOf(u8, line, needle) != null) return i;
+ }
+ return null;
+}
+
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const args = try init.minimal.args.toSlice(init.arena.allocator());
@@ -42,54 +52,110 @@ pub fn main(init: std.process.Init) !void {
// 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor).
try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)");
- // 4. Enter switches to TTY mode from normal and forwards keystrokes to the
- // shell. (Esc no longer reaches tty; Enter is the tty-enter key.)
+ // 3b. ls-on-open: a fresh shell greets with its directory listing. The e2e app
+ // boots in the project dir, so a known file shows up as command output
+ // (visible even in normal mode — only prompt rows are hidden).
+ try hs.expectWaitFor("build.zig", 8000, "new terminals did not auto-ls their directory on open");
+
+ // 3c. Normal-mode TAB = acme middle-click "execute" the word under the cursor.
+ // Drop a one-line executable, echo its path onto body row 0 (clear homes the
+ // cursor first), left-click the path to enter normal + pin the cursor on it,
+ // then TAB. It runs the word in place (no new pane) -> the script's marker.
+ try hs.send("\x02"); // normal -> tty (Ctrl-b)
+ try hs.pump(600);
+ try hs.send("printf '#!/bin/sh\\necho ZZTABRAN\\n' > /tmp/zztabcmd; chmod +x /tmp/zztabcmd; clear; echo /tmp/zztabcmd\r");
+ try hs.pump(1200);
+ try hs.send("\x1b[<0;8;2M\x1b[<0;8;2m"); // left-click the path on body row 0 -> normal + pin
+ try hs.pump(600);
+ try hs.send("\t"); // TAB: execute "/tmp/zztabcmd"
+ try hs.expectWaitFor("ZZTABRAN", 8000, "normal-mode Tab did not execute the word under the cursor");
+ try hs.pump(400);
+
+ // 3d. OUTPUT PADDING: a command composed (typed as an overlay) far below the
+ // prompt runs BELOW where it was typed, not back up at the prompt. The app
+ // pads the shell down with newlines to max(last-edit row, prompt row); the
+ // padding prompts are hidden in text mode, so the output flows under the
+ // composed line. Clear the pane (prompt -> row 0), type a command on a low
+ // row whose OUTPUT marker (PAD13Z) differs from its source (echo PAD...),
+ // run it, and assert the output row is BELOW the typed row. (Screen is 30x100
+ // from step 2; terms[0] is the ~15-row top-left pane.)
+ try hs.send("\x02"); // normal -> tty
+ try hs.pump(400);
+ try hs.send("clear\r"); // prompt back to row 0
+ try hs.pump(900);
+ try hs.send("\x02"); // tty -> normal
+ try hs.pump(400);
+ try hs.send("\x1b[<0;5;10M\x1b[<0;5;10m"); // left-click a low blank row (screen row 9) -> normal + pin
+ try hs.pump(400);
+ try hs.send("i"); // insert
+ try hs.pump(400);
+ try hs.send("echo PAD$((6+7))Z"); // overlay; output is "PAD13Z"
+ try hs.pump(600);
+ try hs.send("\x1b"); // -> normal
+ try hs.pump(400);
+ try hs.send("\x1b[<1;5;10M\x1b[<33;21;10M\x1b[<1;21;10m"); // middle-drag-select the line -> run (padded)
+ try hs.expectWaitFor("PAD13Z", 8000, "padded command did not run");
+ try hs.pump(600);
+ {
+ const text = try hs.screenText();
+ defer gpa.free(text);
+ const typed = rowOf(text, "echo PAD") orelse {
+ hs.dump("composed line vanished before the padding check");
+ return error.ExpectFailed;
+ };
+ const outp = rowOf(text, "PAD13Z") orelse {
+ hs.dump("padded output not found on screen");
+ return error.ExpectFailed;
+ };
+ if (outp <= typed) {
+ hs.dump("output did not land below the composed command (padding failed)");
+ return error.ExpectFailed;
+ }
+ // and it should land RIGHT under the composed line (tight alignment), not
+ // somewhere far below — the hidden padding prompts fill the gap above it.
+ if (outp - typed > 2) {
+ hs.dump("padded output landed too far below the composed line (misaligned)");
+ return error.ExpectFailed;
+ }
+ }
+ // tidy up: drop the lingering overlay so it doesn't ride the scrollback into
+ // later steps (cursor is on the typed row; x selects it, d clears the run).
+ try hs.send("x");
+ try hs.pump(300);
+ try hs.send("d");
+ try hs.pump(300);
+
+ // 4. Ctrl-b toggles raw TTY mode from normal and forwards keystrokes to the
+ // shell. tty is a hidden toggle (Ctrl-b), off the normal editing workflow.
const marker = "PARDES_E2E";
- try hs.send("\r"); // normal -> tty (Enter)
+ try hs.send("\x02"); // normal -> tty (Ctrl-b)
try hs.pump(600);
try hs.send("echo " ++ marker ++ "\r");
try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
- try hs.expectContains("sy /", "no 'sy' indicator after Enter (tty mode)");
+ try hs.expectContains("sy /", "no 'sy' indicator after Ctrl-b (tty mode)");
- // 4b. Enter-to-tty POSITIONING (the start-vs-end distinction). Still in tty:
- // clear to a clean screen, type input WITHOUT running it, go to normal,
- // navigate the modal cursor to the last char of the typed text, then
- // Enter. enterTty uses ghostty's promptClickMove to move the shell's real
- // cursor there, so the next typed char inserts BEFORE that last char.
- // Running it yields "ABCDEXF" — proving the shell cursor was repositioned
- // mid-input. A no-op Enter (cursor stays at end) would append -> "ABCDEFX".
- try hs.send("clear\r");
- try hs.pump(800);
- try hs.send("echo ABCDEF"); // typed input, NOT run (no \r)
- try hs.pump(800);
- try hs.send("\x1b[<0;3;2M\x1b[<0;3;2m"); // left-click body row 0, col 0 (col 3 = past gutter) -> normal
- try hs.pump(600);
- try hs.send("$"); // modal: end of row 0 (lands on 'F')
+ // 4b. Ctrl-b toggles back: tty -> normal. Send Ctrl-b again and confirm the
+ // tag flips off "sy" (raw tty) back to "nm" (normal).
+ try hs.send("\x02"); // tty -> normal (Ctrl-b)
try hs.pump(600);
- try hs.send("\r"); // Enter: normal -> tty, shell cursor moved onto 'F'
+ try hs.expectContains("nm /", "Ctrl-b did not toggle tty back to normal");
+ try hs.send("\x02"); // normal -> tty again for the prompt-hiding step
try hs.pump(600);
- try hs.send("X\r"); // insert 'X' before 'F', then run -> "ABCDEXF"
- try hs.expectWaitFor("ABCDEXF", 10000, "Enter did not reposition the shell cursor mid-input (no ABCDEXF)");
- try hs.pump(600);
- try hs.expectNotContains("ABCDEFX", "Enter left the shell cursor at the end (appended X -> ABCDEFX)");
- // 5. Prompt hiding. In tty mode a command line + its output are both visible.
- // Normal mode shows the real terminal (prompts visible); INSERT mode is the
- // acme compose surface, hiding prompt/command rows but keeping the output.
- // Run a command, left-click to normal, press `i` to enter insert, then
- // check: output present, prompt + command absent.
+ // 5. Prompt hiding in TEXT mode. tty shows the real terminal (command line +
+ // output). BOTH normal and insert hide the prompt/command rows (the acme
+ // compose surface) while keeping output. Run a command in tty, left-click
+ // to NORMAL, and check: output present, the command's prompt row hidden.
try hs.send("echo CMDHIDEZZ\r");
try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
try hs.pump(800); // let tty-mode frames settle
- try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal
- try hs.pump(600);
- try hs.send("i"); // normal -> insert (prompts now hidden)
+ try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal (prompts now hidden)
try hs.pump(600);
- try hs.expectContains("CMDHIDEZZ", "command output not shown in insert mode");
- // The prompt row (with the typed command) is hidden in insert mode. We check
- // "echo CMDHIDEZZ" (specific to this pane's prompt row) rather than
- // "goblin@" because other panes in normal mode show their own prompts.
- try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in insert mode (prompt row not hidden)");
+ try hs.expectContains("CMDHIDEZZ", "command output not shown in normal mode");
+ // The prompt row (with the typed command) is hidden in normal mode now too. We
+ // check "echo CMDHIDEZZ" (this pane's prompt row) rather than "goblin@" since a
+ // raw-tty pane elsewhere could show its own prompt.
+ try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in normal mode (prompt row not hidden)");
// 6. Insert-mode editing + middle-click execute. Click a blank row to pin the
// cursor at text col 0, type a computed echo, then middle-select those 15
@@ -103,16 +169,36 @@ pub fn main(init: std.process.Init) !void {
try hs.pump(600); // let the run build
try hs.send("\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
- // leave insert -> normal so the next step can reach tty via Enter
+ // leave insert -> normal so the next step can reach tty via Ctrl-b
try hs.send("\x1b");
try hs.pump(400);
+ // 6b. FOLLOW OUTPUT: new tty output scrolls the pane to the bottom, so a result
+ // shows even after you've scrolled up. Fill the pane, wheel UP off the
+ // bottom (LN40 disappears), then run a command — the pane snaps back and the
+ // new marker is visible. Done in tty mode so the active pane follows too.
+ try hs.send("\x02"); // normal -> tty
+ try hs.pump(400);
+ try hs.send("clear; for i in $(seq 1 40); do echo LN$i; done\r");
+ try hs.expectWaitFor("LN40", 8000, "filler output never appeared");
+ try hs.pump(500);
+ {
+ var w: usize = 0;
+ while (w < 30) : (w += 1) try hs.send("\x1b[<64;5;5M"); // wheel UP over the pane
+ }
+ try hs.pump(500);
+ try hs.expectNotContains("LN40", "wheel-up did not scroll the pane off the bottom");
+ try hs.send("echo ZZFOLLOW\r");
+ try hs.expectWaitFor("ZZFOLLOW", 8000, "new output did not scroll the pane back to the bottom");
+ try hs.send("\x02"); // tty -> normal (step 7 re-enters via Ctrl-b)
+ try hs.pump(400);
+
// 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
// it: the new terminal lists the dir, so the unique entry appears only if
// the directory was actually opened.
- try hs.send("\r"); // normal -> tty (Enter)
+ try hs.send("\x02"); // normal -> tty (Ctrl-b)
try hs.pump(700);
try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
try hs.pump(2000); // let it run + settle
@@ -180,7 +266,7 @@ pub fn main(init: std.process.Init) !void {
// intro) appears on screen. Middle-click is button 1 (SGR: <1;...M).
// Tag row 0 is the first row of any pane; "Tutor" sits at the end of the
// tag. Click somewhere in the tag's right half to hit it.
- try hs.send("\x1b[<1;45;1M\x1b[<1;45;1m"); // middle-click the tag row (pane 0)
+ try hs.send("\x1b[<1;48;1M\x1b[<1;48;1m"); // middle-click "Tutor" in the tag row (file pane's "nm " prefix shifts it +3)
try hs.expectWaitFor("PARDES TUTOR", 10000, "middle-click 'Tutor' did not spawn the tutor pane");
try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");