summaryrefslogtreecommitdiff
path: root/main.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 08:57:43 -0300
committerGabriel Schneider <[email protected]>2026-06-30 11:43:29 -0300
commit8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch)
tree788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 /main.zig
parentb5b526f465924324e2cd4424f5bacc43e50b7673 (diff)
downloadpardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz
pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip
chinese vibes modal editing
Diffstat (limited to 'main.zig')
-rw-r--r--main.zig923
1 files changed, 820 insertions, 103 deletions
diff --git a/main.zig b/main.zig
index c5ed5085..49413eec 100644
--- a/main.zig
+++ b/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis");
const ghostty_vt = @import("ghostty-vt");
const Term = @import("term.zig");
+const modal = @import("modal.zig");
extern "c" fn forkpty(
amaster: *c_int,
@@ -45,6 +46,18 @@ pub const Command = struct {
} = .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.
+var yank_reg: ?[]u8 = null;
+fn freeYank(gpa: std.mem.Allocator) void {
+ if (yank_reg) |y| gpa.free(y);
+ yank_reg = null;
+}
+fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
+ freeYank(gpa);
+ yank_reg = gpa.dupe(u8, text) catch null;
+}
+
const Loop = vaxis.Loop(@TypeOf(Command.value));
// layout constants
@@ -54,6 +67,7 @@ const MINW: u16 = 10; // min pane width (cells)
const MINH: u16 = 3; // min pane height (cells)
const MAX_TERMS: usize = 16;
const MAX_COLS: usize = 6;
+const PREFIX_W: u16 = 5; // file-pane line-number prefix width: "%4d "
const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
@@ -97,15 +111,20 @@ const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a };
// selections are easy to tell apart against the otherwise-monochrome chrome.
const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
+// modal line-selection (helix `x`) highlight: a warm tan so it reads as
+// keyboard-driven, distinct from the mouse selections above.
+const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
+const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
// Tag builtins. Middle-clicking the name in a window's tag runs the action;
// `builtins_str` is the space-joined name list, produced at comptime.
-const SelectAction = enum { none, send, newcol, delcol, del };
+const SelectAction = enum { none, send, newcol, delcol, del, tutor };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Newcol", .action = .newcol },
.{ .name = "Delcol", .action = .delcol },
.{ .name = "Del", .action = .del },
+ .{ .name = "Tutor", .action = .tutor },
};
const builtins_str = blk: {
var s: []const u8 = "";
@@ -116,11 +135,14 @@ const builtins_str = blk: {
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That
-// marks the prompt rows so we can hide them in text mode.
+// marks the prompt rows so we can hide them in text mode. The A marker also
+// carries cl=line, opting in to ghostty's promptClickMove (arrow-key
+// repositioning of the shell cursor within the typed input) so enterTty can
+// land the shell cursor where the modal cursor navigated (start vs end).
const rc_path = "/tmp/pardes-osc133.bash";
const bash_rc =
\\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
- \\PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
+ \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
\\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
\\trap 'printf "\e]133;C\a"' DEBUG
\\
@@ -224,8 +246,12 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str });
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
- // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal).
- const mode = if (t.editing) "ch" else "sy";
+ // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty).
+ const mode = switch (t.mode) {
+ .normal => "nm",
+ .insert => "in",
+ .tty => "sy",
+ };
return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str });
}
@@ -345,6 +371,35 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
return t;
}
+// Open the built-in Tutor as a read/editable FILE pane: same machinery as
+// openFileView, but the content is the tutor text embedded at comptime (so the
+// binary is self-contained — no external file needed) and the path is a fixed
+// label. Middle-clicking "Tutor" in any tag spawns this. Caller wires it into
+// the layout + frees later.
+const tutor_text = @embedFile("tutor.txt");
+fn openTutorView(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Term {
+ const content = try gpa.dupe(u8, tutor_text);
+ errdefer gpa.free(content);
+ const path = try gpa.dupe(u8, "/Tutor");
+ errdefer gpa.free(path);
+ const t = try gpa.create(Term);
+ errdefer gpa.destroy(t);
+ t.* = .{
+ .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
+ .pid = 0,
+ .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .reader = .{ .any_future = null, .result = {} },
+ .file = .{ .path = path, .content = content, .scroll = 0 },
+ .cols = cols,
+ .rows = rows,
+ .cur_pinned = true,
+ .cur_row = 0,
+ };
+ t.stream = t.term.vtStream();
+ return t;
+}
+
fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void {
t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop });
}
@@ -583,15 +638,42 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
}
+ // modal line selection (helix `x`): highlight the selected body rows across
+ // the text width. Only in normal mode; r0/r1 are absolute body rows.
+ if (t.mode == .normal and t.msel.active) {
+ const lo = @min(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
+ const hi = @max(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
+ var col: u16 = 0;
+ while (col < r.w - GUTTER) : (col += 1) {
+ if (text.readCell(col, row)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.bg = .{ .rgb = msel_bg };
+ nc.style.fg = .{ .rgb = msel_fg };
+ text.writeCell(col, row, nc);
+ }
+ }
+ }
+ }
if (active) {
- if (t.editing) {
- // text mode: cursor at the (scroll-adjusted) edit position. until the
- // user pins it (click/type) it tracks the shell cursor.
+ if (t.mode != .tty) {
+ // text mode (normal/insert): cursor at the (scroll-adjusted) edit
+ // position. until the user pins it (click/type) it tracks the shell cursor.
const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x));
const coff = if (t.cur_pinned) t.cur_off else 0;
const prow = crow - off + @as(i32, BOX_H);
- const cx = renderedCol(t, crow, ccol, coff);
+ // insert mode uses the edit-run coordinate -> visual mapping; normal
+ // mode keeps cur_col already-visual; file panes add the line-number prefix.
+ const cx = if (t.file != null)
+ @as(i32, PREFIX_W) + ccol + @as(i32, coff)
+ else if (t.mode == .insert)
+ renderedCol(t, crow, ccol, coff)
+ else
+ ccol;
if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER))
text.showCursor(@intCast(cx), @intCast(prow));
} else {
@@ -638,6 +720,657 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
+// ---- modal editing helpers (wired onto Term by the key handler) ----
+
+const PaneLines = struct {
+ lines: []const []const u8,
+ row0: i32, // absolute row of lines[0]; cursor lines-row = cur_row - row0
+ 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.
+fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines {
+ if (t.file) |f| {
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, f.content, '\n');
+ 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);
+ 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 };
+}
+
+fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void {
+ alloc.free(pl.lines);
+ if (pl.body) |b| alloc.free(b);
+}
+
+fn toModalCursor(t: *Term, pl: PaneLines) modal.Cursor {
+ const r: i32 = t.cur_row - pl.row0;
+ return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, t.cur_col)) };
+}
+
+// write a modal Cursor back to the Term (absolute row, pin, clear run offset)
+fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void {
+ t.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
+ t.cur_col = @intCast(c.col);
+ t.cur_off = 0;
+ t.cur_pinned = true;
+}
+
+// apply a motion result: move cursor, collapse any line selection, keep it visible
+fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void {
+ fromModalCursor(t, pl, c);
+ t.msel.active = false;
+ t.pending = 0;
+ ensureCursorVisible(t);
+}
+
+fn ensureCursorVisible(t: *Term) void {
+ const off = paneScroll(t);
+ const last = off + @as(i32, t.rows) - 1;
+ if (t.cur_row < off) paneScrollBy(t, t.cur_row - off)
+ else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last);
+}
+
+fn alignViewportTop(t: *Term) void {
+ paneScrollBy(t, t.cur_row - paneScroll(t));
+}
+fn alignViewportBottom(t: *Term) void {
+ paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows) - 1));
+}
+fn alignViewportCenter(t: *Term) void {
+ paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows / 2)));
+}
+
+// pin the cursor to the shell's current cursor (terminal) or the scroll top
+// (file), the first time the user interacts in normal/insert mode.
+fn pinCursor(t: *Term) void {
+ if (t.cur_pinned) return;
+ const off: i32 = paneScroll(t);
+ if (t.file != null) {
+ t.cur_row = off;
+ t.cur_col = 0;
+ } else {
+ t.cur_row = @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
+ t.cur_col = @intCast(t.term.screens.active.cursor.x);
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+}
+
+// the last navigable row: file -> last content line; terminal -> viewport bottom.
+fn lastNavRow(t: *Term) i32 {
+ if (t.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ return paneScroll(t) + @as(i32, t.rows) - 1;
+}
+
+const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+// enter insert mode, positioning the cursor per `where`. For file panes,
+// open_below/open_above splice a newline into the content; terminals just move.
+fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, where: InsertAt) void {
+ pinCursor(t);
+ const pl = paneCursorLines(talloc, t) catch {
+ t.mode = .insert;
+ t.msel.active = false;
+ t.pending = 0;
+ return;
+ };
+ defer freePaneLines(talloc, pl);
+ const cur = toModalCursor(t, pl);
+ const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ switch (where) {
+ .at => {},
+ .append => fromModalCursor(t, pl, modal.charRight(pl.lines, cur)),
+ .line_start => fromModalCursor(t, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .line_end => {
+ t.cur_col = @intCast(llen);
+ t.cur_pinned = true;
+ },
+ .open_below => {
+ if (t.file) |*f| {
+ const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
+ t.cur_col = 0;
+ } else {
+ const bottom = paneScroll(t) + @as(i32, t.rows) - 1;
+ t.cur_row = @min(bottom, t.cur_row + 1);
+ t.cur_col = 0;
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ },
+ .open_above => {
+ if (t.file) |*f| {
+ const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ // current line shifts down; the new empty line is at cur.row
+ t.cur_col = 0;
+ } else {
+ const top = paneScroll(t);
+ t.cur_row = @max(top, t.cur_row - 1);
+ t.cur_col = 0;
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ },
+ }
+ // run-offset for the terminal insert model: `i` inserts before the run at the
+ // cursor (off 0); the others append after any run already sitting there.
+ t.cur_off = if (where == .at) 0 else if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ t.mode = .insert;
+ t.msel.active = false;
+ t.pending = 0;
+ ensureCursorVisible(t);
+}
+
+// helix `x`: extend the line selection down by one (or start it at the cursor).
+fn lineSelect(t: *Term) void {
+ if (!t.msel.active) {
+ t.msel = .{ .active = true, .r0 = t.cur_row, .r1 = t.cur_row };
+ } else {
+ const last = lastNavRow(t);
+ t.msel.r1 = @min(t.msel.r1 + 1, last);
+ t.cur_row = t.msel.r1;
+ }
+ t.cur_col = 0;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.pending = 0;
+}
+
+// drop all insertion runs whose row is in [r0, r1] (terminal delete/change).
+fn clearEditsInRange(t: *Term, r0: i32, r1: i32) void {
+ var w: usize = 0;
+ for (t.edits[0..t.nedits]) |e| {
+ if (e.row >= r0 and e.row <= r1) continue;
+ t.edits[w] = e;
+ w += 1;
+ }
+ t.nedits = w;
+}
+
+// join the rendered body rows [r0, r1] (absolute) with '\n'. `alloc` is scratch;
+// caller (setYank) copies it into the gpa-owned register before the frame ends.
+fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 {
+ const body = bodyText(alloc, t) catch return "";
+ defer alloc.free(body);
+ const off = paneScroll(t);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < r0 or abs > r1) continue;
+ if (abs > r0) out.append(alloc, '\n') catch return "";
+ out.appendSlice(alloc, ln) catch return "";
+ }
+ return out.toOwnedSlice(alloc) catch "";
+}
+
+fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = d.content;
+ setYank(gpa, d.deleted);
+ gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ t.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
+ t.cur_col = 0;
+ } else {
+ clearEditsInRange(t, r0, r1);
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ }
+ t.msel.active = false;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ } else if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ const new = modal.deleteChar(gpa, f.content, c) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ } // terminal, no selection: can't delete shell output -> no-op
+}
+
+fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(gpa, '\n') catch break;
+ buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
+ }
+ setYank(gpa, buf.items);
+ buf.deinit(gpa);
+ } else {
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ }
+ t.cur_row = r0;
+ t.cur_col = 0;
+ t.msel.active = false;
+ } else if (t.file) |f| {
+ setYank(gpa, modal.lineSlice(f.content, @intCast(@max(0, t.cur_row))));
+ } else {
+ setYank(gpa, yankRows(talloc, t, t.cur_row, t.cur_row));
+ }
+}
+
+fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
+ const y = yank_reg orelse return;
+ if (t.file) |*f| {
+ const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row += 1;
+ t.cur_col = 0;
+ } else {
+ // terminal: spill the yanked text into an insertion run at the cursor
+ insertRunText(t, y);
+ }
+ t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+}
+
+fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ // keep one empty line at r0, drop the rest; yank the removed text
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(gpa, '\n') catch break;
+ buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
+ }
+ setYank(gpa, buf.items);
+ buf.deinit(gpa);
+ if (r1 > r0) {
+ const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = d.content;
+ gpa.free(d.deleted);
+ }
+ const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = cl;
+ t.cur_row = r0;
+ t.cur_col = 0;
+ } else {
+ clearEditsInRange(t, r0, r1);
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ t.cur_row = r0;
+ t.cur_col = 0;
+ }
+ t.msel.active = false;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.mode = .insert;
+ t.pending = 0;
+ ensureCursorVisible(t);
+ } else if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ const new = modal.deleteChar(gpa, f.content, c) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.mode = .insert;
+ t.cur_off = 0;
+ t.pending = 0;
+ } else {
+ // terminal, no selection: just enter insert at the cursor
+ t.mode = .insert;
+ t.cur_off = 0;
+ t.pending = 0;
+ }
+}
+
+// insert `text` into the terminal run model at the cursor (the existing char-mode
+// splice). Factored out so `p`aste reuses it.
+fn insertRunText(t: *Term, text: []const u8) void {
+ var idx: ?usize = runAt(t, t.cur_row, t.cur_col);
+ if (idx == null and t.nedits < Term.MAX_EDITS) {
+ t.edits[t.nedits] = .{ .row = t.cur_row, .col = t.cur_col };
+ t.nedits += 1;
+ idx = t.nedits - 1;
+ }
+ if (idx) |ei| {
+ const e = &t.edits[ei];
+ for (text) |ch| {
+ if (e.len >= Term.EDIT_CAP) break;
+ if (t.cur_off > e.len) t.cur_off = e.len;
+ var k: usize = e.len;
+ while (k > t.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
+ e.text[t.cur_off] = ch;
+ e.len += 1;
+ t.cur_off += 1;
+ }
+ }
+}
+
+// normal-mode (helix-style) key dispatch on a pane. The cursor is a block that
+// moves over the rendered text (file content lines, or the terminal viewport);
+// paneCursorLines builds that view per keystroke. Bindings (helix defaults,
+// adapted to the acme text model):
+// move h j k l (or arrows) word w b e / W B E (long)
+// line 0 $ ^ (or home/end) goto g g (top) G (last) g e g h g l
+// page Ctrl-d/u (half) Ctrl-f/b (full) PgUp/PgDn
+// 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)
+// 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
+// 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
+// prompt start when the input is empty, or at/after typed text at the end. Then
+// hand the keyboard to the pty. If the shell isn't at a prompt (outputting) or
+// the cursor is above the prompt, just switch — the shell cursor stays put.
+// Requires OSC 133 with cl=line (our bash_rc emits it).
+fn enterTty(t: *Term) void {
+ const screen = t.term.screens.active;
+ if (t.term.cursorIsAtPrompt()) {
+ const off = paneScroll(t);
+ const vp_row: i32 = t.cur_row - off;
+ if (vp_row >= 0) {
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, t.cur_col)), .y = @intCast(vp_row) } })) |click_pin| {
+ const cursor_pin = screen.cursor.page_pin.*;
+ var pit = cursor_pin.promptIterator(.left_up, null);
+ if (pit.next()) |prompt_pin| {
+ if (!click_pin.before(prompt_pin)) {
+ const m = screen.promptClickMove(click_pin);
+ var i: usize = 0;
+ while (i < m.left) : (i += 1) termWrite(t, "\x1b[D");
+ i = 0;
+ while (i < m.right) : (i += 1) termWrite(t, "\x1b[C");
+ }
+ }
+ }
+ }
+ }
+ t.mode = .tty;
+ t.msel.active = false;
+ t.pending = 0;
+}
+
+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);
+ const lines = pl.lines;
+ const half: usize = @max(1, t.rows / 2);
+
+ // prefix continuations
+ if (t.pending == 'g') {
+ t.pending = 0;
+ if (key.matches('g', .{})) return applyMove(t, pl, modal.gotoFirst());
+ if (key.matches('e', .{})) return applyMove(t, pl, modal.gotoLast(lines));
+ if (key.matches('h', .{})) return applyMove(t, pl, modal.lineStart(cur));
+ if (key.matches('l', .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
+ return;
+ }
+ if (t.pending == 'z') {
+ t.pending = 0;
+ if (key.matches('t', .{})) return alignViewportTop(t);
+ if (key.matches('z', .{})) return alignViewportCenter(t);
+ if (key.matches('b', .{})) return alignViewportBottom(t);
+ return;
+ }
+ if (key.matches('g', .{})) {
+ t.pending = 'g';
+ return;
+ }
+ if (key.matches('z', .{})) {
+ t.pending = 'z';
+ return;
+ }
+
+ // movement
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return applyMove(t, pl, modal.charLeft(cur));
+ if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return applyMove(t, pl, modal.charRight(lines, cur));
+ if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return applyMove(t, pl, modal.lineDown(lines, cur));
+ if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return applyMove(t, pl, modal.lineUp(lines, cur));
+ if (key.matches('w', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, false));
+ if (key.matches('b', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, false));
+ if (key.matches('e', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, false));
+ if (key.matches('W', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, true));
+ if (key.matches('B', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, true));
+ if (key.matches('E', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, true));
+ if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) return applyMove(t, pl, modal.lineStart(cur));
+ if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
+ if (key.matches('^', .{})) return applyMove(t, pl, modal.firstNonWsOf(lines, cur));
+ if (key.matches('G', .{})) return applyMove(t, pl, modal.gotoLast(lines));
+ if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{}))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
+ if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{}))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
+ if (key.matches('f', .{ .ctrl = true }))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, t.rows))));
+ if (key.matches('b', .{ .ctrl = true }))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, t.rows))));
+
+ // insert entry
+ if (key.matches('i', .{})) return enterInsert(gpa, talloc, t, .at);
+ if (key.matches('a', .{})) return enterInsert(gpa, talloc, t, .append);
+ if (key.matches('I', .{})) return enterInsert(gpa, talloc, t, .line_start);
+ if (key.matches('A', .{})) return enterInsert(gpa, talloc, t, .line_end);
+ if (key.matches('o', .{})) return enterInsert(gpa, talloc, t, .open_below);
+ if (key.matches('O', .{})) return enterInsert(gpa, talloc, t, .open_above);
+
+ // line selection + edit (helix `x` then d/c/y; p pastes)
+ if (key.matches('x', .{})) return lineSelect(t);
+ 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);
+}
+
+// insert-mode key dispatch on a pane. File: real content editing; terminal: the
+// char-mode run splice (text shifts the underlying tty row right).
+fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
+ if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ if (key.text) |text| {
+ const new = modal.insertAt(gpa, f.content, c, text) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ for (text) |ch| {
+ if (ch == '\n') {
+ t.cur_row += 1;
+ t.cur_col = 0;
+ } else t.cur_col += 1;
+ }
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ return;
+ }
+ switch (key.codepoint) {
+ vaxis.Key.enter => {
+ const new = modal.insertAt(gpa, f.content, c, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row += 1;
+ t.cur_col = 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ },
+ vaxis.Key.backspace => {
+ if (t.cur_col > 0) {
+ const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_col -= 1;
+ } else if (t.cur_row > 0) {
+ const prevlen = modal.lineSlice(f.content, c.row - 1).len;
+ const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row -= 1;
+ t.cur_col = @intCast(prevlen);
+ }
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ },
+ vaxis.Key.left, vaxis.Key.right, vaxis.Key.up, vaxis.Key.down => {
+ const pl = paneCursorLines(talloc, t) catch return;
+ defer freePaneLines(talloc, pl);
+ const cur0 = toModalCursor(t, pl);
+ const nc = switch (key.codepoint) {
+ vaxis.Key.left => modal.charLeft(cur0),
+ vaxis.Key.right => modal.charRight(pl.lines, cur0),
+ vaxis.Key.up => modal.lineUp(pl.lines, cur0),
+ vaxis.Key.down => modal.lineDown(pl.lines, cur0),
+ else => cur0,
+ };
+ fromModalCursor(t, pl, nc);
+ ensureCursorVisible(t);
+ },
+ else => {},
+ }
+ return;
+ }
+
+ // terminal: char-mode run insert (unchanged from the original editor)
+ if (!t.cur_pinned) pinCursor(t);
+ if (key.text) |text| {
+ insertRunText(t, text);
+ } else switch (key.codepoint) {
+ vaxis.Key.left => {
+ if (t.cur_off > 0) t.cur_off -= 1 else {
+ t.cur_col = @max(0, t.cur_col - 1);
+ t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ }
+ },
+ vaxis.Key.right => {
+ const rl: u16 = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ if (t.cur_off < rl) t.cur_off += 1 else {
+ t.cur_col += 1;
+ t.cur_off = 0;
+ }
+ },
+ vaxis.Key.up => {
+ const off = paneScroll(t);
+ t.cur_row = @max(off, t.cur_row - 1);
+ t.cur_off = 0;
+ },
+ vaxis.Key.down => {
+ const off = paneScroll(t);
+ t.cur_row = @min(off + @as(i32, t.rows) - 1, t.cur_row + 1);
+ t.cur_off = 0;
+ },
+ else => {},
+ }
+}
+
+// tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as
+// the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...).
+fn forwardToPty(t: *Term, key: vaxis.Key) void {
+ var cbuf: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.mods.ctrl) {
+ const cp = key.codepoint;
+ if (cp >= 'a' and cp <= 'z') {
+ cbuf[0] = @intCast(cp - 0x60);
+ break :blk cbuf[0..1];
+ }
+ if (cp == '@') {
+ cbuf[0] = 0;
+ break :blk cbuf[0..1];
+ }
+ if (cp >= '[' and cp <= '_') {
+ cbuf[0] = @intCast(cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ if (cp >= 'A' and cp <= 'Z') {
+ cbuf[0] = @intCast(cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ }
+ if (key.text) |text| break :blk text;
+ break :blk switch (key.codepoint) {
+ vaxis.Key.enter => "\r",
+ vaxis.Key.backspace => "\x7f",
+ vaxis.Key.tab => "\t",
+ vaxis.Key.escape => "\x1b",
+ vaxis.Key.up => "\x1b[A",
+ vaxis.Key.down => "\x1b[B",
+ vaxis.Key.right => "\x1b[C",
+ vaxis.Key.left => "\x1b[D",
+ else => null,
+ };
+ };
+ if (bytes) |b| termWrite(t, b);
+}
+
+// move focus to the neighbor pane in `dir`, if any. Rects are the current tiling.
+fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, dir: enum { left, right, up, down }) void {
+ const a = rects[active.*];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (terms, 0..) |slot, i| {
+ if (slot == null or i == active.*) continue;
+ const r = rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h; // vertical overlap
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w; // horizontal overlap
+ const ok = switch (dir) {
+ .left => r.x + r.w <= a.x and vov,
+ .right => r.x >= a.x + a.w and vov,
+ .up => r.y + r.h <= a.y and hov,
+ .down => r.y >= a.y + a.h and hov,
+ };
+ if (!ok) continue;
+ const d: i32 = switch (dir) {
+ .left => a.x - (r.x + r.w),
+ .right => r.x - (a.x + a.w),
+ .up => a.y - (r.y + r.h),
+ .down => r.y - (a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| active.* = b;
+}
+
pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
@@ -650,6 +1383,7 @@ pub fn main(init: std.process.Init) !void {
// the retained vaxis screen can keep referencing it through vx.render().
var frame_arena: std.heap.ArenaAllocator = .init(gpa);
defer frame_arena.deinit();
+ defer freeYank(gpa);
var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render()
var tty_buf: [0x10000]u8 = undefined;
@@ -715,6 +1449,7 @@ pub fn main(init: std.process.Init) !void {
var drag: Drag = .none;
var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it
var hover_row: u16 = 0;
+ var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus
while (true) {
const event = try loop.nextEvent();
@@ -1034,14 +1769,39 @@ pub fn main(init: std.process.Init) !void {
} 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 char mode and
- // anchors the cursor at the selection end. the
- // row is stored absolute (visual + scroll off) so
- // it rides the scrollback. clicks on the tag (row
- // < BOX_H) don't edit.
+ // 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.
const sl = t.sel[@intFromEnum(Button.left)];
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0) {
@@ -1050,7 +1810,9 @@ pub fn main(init: std.process.Init) !void {
t.cur_col = 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.editing = true;
+ t.mode = .normal;
+ t.msel.active = false;
+ t.pending = 0;
}
}
}
@@ -1060,10 +1822,27 @@ pub fn main(init: std.process.Init) !void {
drag = .none;
}
},
- .key_press => |key| {
+ .key_press => |key| kpress: {
if (key.matches('c', .{ .ctrl = true })) break;
if (terms[active]) |at| {
+ // Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l).
+ if (ctrl_w_pending) {
+ ctrl_w_pending = false;
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left)
+ else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right)
+ else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up)
+ else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down);
+ if (terms[active]) |nt| nt.pending = 0;
+ break :kpress;
+ }
+ if (key.matches('w', .{ .ctrl = true })) {
+ ctrl_w_pending = true;
+ break :kpress;
+ }
+
+ // global window ops (any mode): Alt-n new terminal below,
+ // Alt-c move active terminal into a fresh column.
if (key.matches('n', .{ .alt = true })) {
// new terminal below the active one, in its column.
// ponytail: forks from the now-multithreaded process;
@@ -1080,97 +1859,31 @@ pub fn main(init: std.process.Init) !void {
active = slot;
} else |_| {}
}
- } else if (key.matches('c', .{ .alt = true })) {
+ break :kpress;
+ }
+ if (key.matches('c', .{ .alt = true })) {
// move active terminal into a fresh column
const f = layoutFindTerm(&layout, active).?;
if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) {
layoutRemove(&layout, active);
layoutAppendColumn(&layout, active);
}
- } else if (key.matches('d', .{ .ctrl = true })) {
- paneScrollBy(at, 15);
- } else if (key.matches('u', .{ .ctrl = true })) {
- paneScrollBy(at, -15);
- } else if (at.editing and at.file == null and key.matches(vaxis.Key.escape, .{})) {
- at.editing = false; // leave char mode (don't forward Esc); files have no tty mode
- } else if (at.editing) {
- // char mode: printable text inserts at the cursor (shifting
- // the current run); arrows move the cursor; backspace/etc.
- // are no-ops for now (deletion comes later).
- if (key.text) |text| {
- // first keystroke pins the cursor (it was tracking the
- // shell cursor until now).
- if (!at.cur_pinned) {
- const coff: i32 = paneScroll(at);
- if (at.file != null) {
- at.cur_row = coff;
- at.cur_col = 0;
- } else {
- at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff;
- at.cur_col = @intCast(at.term.screens.active.cursor.x);
- }
- at.cur_off = 0;
- at.cur_pinned = true;
- }
- // find or create the run at the cursor
- var idx: ?usize = runAt(at, at.cur_row, at.cur_col);
- if (idx == null and at.nedits < Term.MAX_EDITS) {
- at.edits[at.nedits] = .{ .row = at.cur_row, .col = at.cur_col };
- at.nedits += 1;
- idx = at.nedits - 1;
- }
- if (idx) |ei| {
- const e = &at.edits[ei];
- for (text) |ch| {
- if (e.len >= Term.EDIT_CAP) break;
- if (at.cur_off > e.len) at.cur_off = e.len;
- var k: usize = e.len;
- while (k > at.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
- e.text[at.cur_off] = ch;
- e.len += 1;
- at.cur_off += 1;
- }
- }
- } else switch (key.codepoint) {
- vaxis.Key.left => {
- if (at.cur_off > 0) at.cur_off -= 1 else {
- at.cur_col = @max(0, at.cur_col - 1);
- at.cur_off = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0;
- }
- },
- vaxis.Key.right => {
- const rl: u16 = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0;
- if (at.cur_off < rl) at.cur_off += 1 else {
- at.cur_col += 1;
- at.cur_off = 0;
- }
- },
- vaxis.Key.up => {
- const off: i32 = paneScroll(at);
- at.cur_row = @max(off, at.cur_row - 1);
- at.cur_off = 0;
- },
- vaxis.Key.down => {
- const off: i32 = paneScroll(at);
- at.cur_row = @min(off + @as(i32, at.rows) - 1, at.cur_row + 1);
- at.cur_off = 0;
- },
- else => {},
- }
- } else {
- // sym mode: forward to the pty as terminal input.
- const bytes: ?[]const u8 = if (key.text) |text| text else switch (key.codepoint) {
- vaxis.Key.enter => "\r",
- vaxis.Key.backspace => "\x7f",
- vaxis.Key.tab => "\t",
- vaxis.Key.escape => "\x1b",
- vaxis.Key.up => "\x1b[A",
- vaxis.Key.down => "\x1b[B",
- vaxis.Key.right => "\x1b[C",
- vaxis.Key.left => "\x1b[D",
- else => null,
- };
- if (bytes) |b| termWrite(at, b);
+ 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.
+ switch (at.mode) {
+ .normal => handleNormal(gpa, frame_arena.allocator(), at, key),
+ .insert => {
+ if (key.matches(vaxis.Key.escape, .{})) {
+ at.mode = .normal;
+ at.msel.active = false;
+ at.pending = 0;
+ } else handleInsert(gpa, frame_arena.allocator(), at, key);
+ },
+ .tty => forwardToPty(at, key),
}
}
},
@@ -1461,11 +2174,15 @@ 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 (editing) hides shell prompt rows; tty mode shows everything.
+ // 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.
var flagbuf: [512]bool = undefined;
const nrows: usize = @min(t.rows, flagbuf.len);
const flags = flagbuf[0..nrows];
- if (t.editing) promptFlags(t, flags) else @memset(flags, false);
+ if (t.mode == .insert) 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