summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-16 15:49:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:44:42 -0300
commit1551e409c31992437cb2fa864f576d45c8433801 (patch)
treee2fae8451f87b735a1360c7c2e383fdc40165789 /src/term_pane.zig
parentbe2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff)
downloadpardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz
pardes-1551e409c31992437cb2fa864f576d45c8433801.zip
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig678
1 files changed, 658 insertions, 20 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 1243dfa1..adba1cd6 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -1,24 +1,440 @@
//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt
-//! emulator — reading the grid back out as text (for motions and for the
-//! body), translating the emulator's own cell styles into ours, the pty
-//! replies ghostty hands back through a callback, and the edit-buffer undo
-//! snapshots that only exist because a terminal's "content" is a live grid
-//! rather than a []u8.
+//! emulator — constructing and replaying the emulator, reading its grid back
+//! out as text (for motions and for the body), translating its cell styles
+//! into ours, handling the pty replies it hands back through a callback, and
+//! keeping the edit-buffer undo snapshots that only exist because a terminal's
+//! "content" is a live grid rather than a []u8.
//!
-//! What is NOT here: the edit buffer's SEMANTICS (Pane.ovl, surfRow/gridRow)
-//! live on Pane in pardes.zig, because the motion, insert and undo machinery
-//! is shared with file panes verbatim — a terminal in insert mode behaving
-//! exactly like a text file is the whole design, and splitting the row math
-//! away from the code that uses it would only hide that.
+//! Shared modal edit semantics and the pane-wide screen/grid coordinate
+//! invariants remain on Pane in pardes.zig. Terminal-only projection, history
+//! snapshots, and cell styling live here, so the core does not need to know
+//! how a live terminal becomes an editable text surface.
const std = @import("std");
const ghostty_vt = @import("ghostty-vt");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
-const EditSnap = pardes.EditSnap;
-const Ovl = pardes.Ovl;
+const Key = pardes.Key;
+const EditText = pardes.EditText;
const modal = @import("modal.zig");
const config = @import("config.zig");
+const dump = @import("dump.zig");
+
+pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+
+/// The owned text standing in for `rows` live terminal rows, beginning at
+/// absolute surface row `row`. The emulator grid remains untouched underneath.
+pub const EditBuffer = struct {
+ row: i32 = 0,
+ rows: i32 = 1,
+ text: []u8 = &.{},
+};
+
+/// One whole-state terminal edit boundary. Null means the pane has not yet
+/// materialized an edit buffer; non-null snapshots own their text.
+pub const Snapshot = struct {
+ ovl: ?EditBuffer,
+ cur_row: i32,
+ cur_col: i32,
+ vsel: pardes.CharSel,
+};
+
+/// Command bytes aimed at a shell whose prompt does not exist yet. The host
+/// resolves the actual executable after the core has already queued `.spawn`,
+/// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration
+/// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are
+/// opened immediately by `shellSpawned` and let the pty buffer input until the
+/// child reads it.
+///
+/// This is pane state, not a Pardes-wide job table. A reused pane slot can
+/// therefore never inherit a command intended for the shell it replaced.
+pub const PendingCommand = struct {
+ bytes: []u8 = &.{},
+ wait: enum { none, spawn, input } = .none,
+};
+
+/// A terminal constructed by `newShell` cannot safely receive a command until
+/// the native host has at least completed forkpty. Dump-replay terminals do
+/// not call this: they are dead grids, not half-spawned children.
+pub fn armShellSpawn(pane: *Pane) void {
+ std.debug.assert(pane.pending_command.bytes.len == 0);
+ pane.pending_command.wait = .spawn;
+}
+
+/// Own `command` until this pane's new child can accept it. False means the
+/// gate is already open and the caller should emit in the ordinary way.
+/// Multiple gestures before the prompt appears retain their byte order in one
+/// flat allocation; each command gets exactly the CR execute normally emits.
+pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool {
+ if (pane.pending_command.wait == .none) return false;
+ const old_len = pane.pending_command.bytes.len;
+ const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1));
+ const bytes = if (old_len == 0)
+ try pane.gpa.alloc(u8, new_len)
+ else
+ try pane.gpa.realloc(pane.pending_command.bytes, new_len);
+ @memcpy(bytes[old_len..][0..command.len], command);
+ bytes[new_len - 1] = '\r';
+ pane.pending_command.bytes = bytes;
+ // An explicit command replaces the automatic greeting. Otherwise both
+ // would be released by the same first prompt and `ls` would follow what
+ // the user actually asked to run.
+ pane.greet = false;
+ return true;
+}
+
+/// The host successfully forked `pane`. `prompt_marks` describes the argv it
+/// ACTUALLY used, not the configured shell name: a staged-rc failure or an
+/// unsupported family is an honest unmarked shell and must not wait forever
+/// for an OSC sequence it cannot produce.
+pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return;
+ if (prompt_marks) {
+ pane.pending_command.wait = .input;
+ releasePendingCommand(p, id, pane, false);
+ } else {
+ // There is no semantic event on which an automatic greeting can be
+ // safely based. Explicit commands still release below (the pty owns
+ // their buffering); silently omit the cosmetic `ls` rather than race
+ // an unknown shell's startup and possibly type into its rc program.
+ pane.greet = false;
+ releasePendingCommand(p, id, pane, true);
+ }
+}
+
+/// Called from the ordinary sync after terminal output has been parsed. The
+/// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting
+/// share the exact same definition of "readline owns input".
+pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void {
+ if (pane.pending_command.wait == .input)
+ releasePendingCommand(p, id, pane, promptInputReady(pane));
+}
+
+fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void {
+ if (!ready) return;
+ const bytes = pane.pending_command.bytes;
+ pane.pending_command = .{};
+ if (bytes.len > 0) {
+ p.emitWrite(id, bytes);
+ pane.gpa.free(bytes);
+ }
+}
+
+pub fn deinitPendingCommand(pane: *Pane) void {
+ if (pane.pending_command.bytes.len > 0)
+ pane.gpa.free(pane.pending_command.bytes);
+ pane.pending_command = .{};
+}
+
+/// The emulator stores an Io value for optional kitty-image work. The browser
+/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend
+/// merely to construct a replay-only terminal.
+pub fn terminalIo() std.Io {
+ return if (comptime pardes.platform == .web)
+ std.Io.failing
+ else
+ std.Io.Threaded.global_single_threaded.io();
+}
+
+/// Allocate the live emulator half of a terminal pane. Slot ownership, serial
+/// assignment, and spawn effects remain core lifecycle invariants.
+pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try gpa.create(Pane);
+ errdefer gpa.destroy(pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .gpa = gpa,
+ .cols = cols,
+ .rows = rows,
+ };
+ pane.stream = pane.vt.vtStream();
+ // Answer terminal queries (DSR/DA/kitty) back into the pty, else
+ // crossterm apps (nushell, helix, fzf) block on the reply forever.
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ return pane;
+}
+
+/// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy
+/// stay with the core; raw VT replay and viewport restoration belong here.
+pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
+ const terminal = src.terminal.?;
+ const bytes = if (terminal.stream_b64.len > 0)
+ try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64)
+ else
+ &.{};
+ const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
+ if (bytes.len > 0) {
+ ingest(pane, bytes);
+ pane.vt.screens.active.scroll(.active);
+ if (src.scroll > 0)
+ pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ }
+ return pane;
+}
+
+/// Feed the emulator and retain the bounded suffix a dump can replay. Live
+/// output and restoration share this byte path, then apply different views.
+fn ingest(pane: *Pane, bytes: []const u8) void {
+ if (bytes.len >= pane.tty_stream.len) {
+ const kept = bytes[bytes.len - pane.tty_stream.len ..];
+ @memcpy(pane.tty_stream[0..], kept);
+ pane.tty_stream_head = 0;
+ pane.tty_stream_len = pane.tty_stream.len;
+ } else {
+ const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len);
+ pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len;
+ pane.tty_stream_len -= overflow;
+ const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len;
+ const first = @min(bytes.len, pane.tty_stream.len - tail);
+ @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]);
+ @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]);
+ pane.tty_stream_len += bytes.len;
+ }
+ pane.stream.nextSlice(bytes);
+}
+
+/// Return the replay ring in chronological order. Wrapped records are copied
+/// into `allocator`; contiguous records remain a borrowed slice of the pane.
+fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
+ if (pane.tty_stream_len == 0) return &.{};
+ if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len)
+ return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len];
+ const out = try allocator.alloc(u8, pane.tty_stream_len);
+ const first = pane.tty_stream.len - pane.tty_stream_head;
+ @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]);
+ @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]);
+ return out;
+}
+
+/// Record and parse one live pty read, invalidate its motion surface, and
+/// follow it only when the body (possibly parked under a tag edit) is raw.
+pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
+ ingest(pane, bytes);
+ p.shell_rows.markStale(pane);
+ const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode;
+ if (body_mode == .tty) pane.vt.screens.active.scroll(.active);
+}
+
+/// True only after OSC 133 B ended the prompt and handed the cursor to shell
+/// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt`
+/// phase; that is right for navigation but too early to inject a greeting —
+/// readline may not own echo yet and would leave `ls` on an unmarked row.
+pub fn promptInputReady(pane: *const Pane) bool {
+ return pane.vt.screens.active_key != .alternate and
+ pane.vt.screens.active.cursor.semantic_content == .input;
+}
+
+test "fresh-shell greeting waits for OSC 133 B input phase" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {} // initial spawn
+
+ p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforeOutput,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } });
+ try std.testing.expect(!promptInputReady(pane));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.GreetedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ try std.testing.expect(promptInputReady(pane));
+ var greeted = false;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"),
+ else => {},
+ };
+ try std.testing.expect(greeted);
+ try std.testing.expect(!pane.greet);
+}
+
+test "fresh-shell commands preserve order and wait for OSC 133 B" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet
+
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first"));
+ try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second"));
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforeFork,
+ else => {},
+ };
+
+ p.acknowledgeShell(0, "/bin/bash", true);
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedBeforePrompt,
+ else => {},
+ };
+ p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.CommandEscapedDuringPrompt,
+ else => {},
+ };
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } });
+ var sent: [64]u8 = undefined;
+ var sent_len: usize = 0;
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => |write| {
+ const bytes = write.bytes.slice();
+ @memcpy(sent[sent_len..][0..bytes.len], bytes);
+ sent_len += bytes.len;
+ },
+ else => {},
+ };
+ try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]);
+ try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait);
+}
+
+test "unmarked fresh shells omit the automatic greeting" {
+ if (pardes.platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{});
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ while (p.nextEffect()) |_| {}
+ try std.testing.expect(pane.greet);
+
+ p.acknowledgeShell(0, "/bin/sh", false);
+ try std.testing.expect(!pane.greet);
+ try std.testing.expectEqual(.none, pane.pending_command.wait);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } });
+ while (p.nextEffect()) |effect| switch (effect) {
+ .write => return error.UnmarkedGreetingEscaped,
+ else => {},
+ };
+}
+
+/// Encode one key for the program that owns a raw terminal and queue its pty
+/// write. Global chords and mode routing have already been handled by core.
+pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ var control: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.ctrl) {
+ if (key.cp >= 'a' and key.cp <= 'z') {
+ control[0] = @intCast(key.cp - 0x60);
+ break :blk control[0..1];
+ }
+ // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range.
+ if (key.cp >= '@' and key.cp <= '_') {
+ control[0] = @intCast(key.cp - 0x40);
+ break :blk control[0..1];
+ }
+ }
+ if (key.text.len > 0) break :blk key.text;
+ break :blk switch (key.cp) {
+ Key.enter => "\r",
+ Key.backspace => "\x7f",
+ Key.tab => "\t",
+ Key.escape => "\x1b",
+ Key.up => "\x1b[A",
+ Key.down => "\x1b[B",
+ Key.right => "\x1b[C",
+ Key.left => "\x1b[D",
+ Key.delete => "\x1b[3~",
+ else => null,
+ };
+ };
+ if (bytes) |encoded|
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } });
+}
+
+/// Enter raw tty, handing a pinned modal cursor back to the shell prompt when
+/// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode,
+/// so translate its column through the hidden prompt before asking ghostty for
+/// the arrow-key movement the child understands.
+pub fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const screen = pane.vt.screens.active;
+ if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
+ const goff: i32 = @intCast(screen.pages.scrollbar().offset);
+ const vp_row = pane.gridRow(pane.cur_row) - goff;
+ if (vp_row < 0) break :handoff;
+
+ var grid_col: i32 = @max(0, pane.cur_col);
+ if (screen.pages.pin(.{ .viewport = .{ .x = 0, .y = @intCast(vp_row) } })) |row_pin| {
+ if (row_pin.rowAndCell().row.semantic_prompt != .none) switch (promptCut(row_pin)) {
+ .cut => |cols| grid_col += @intCast(cols),
+ .keep, .blank => {},
+ };
+ }
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intCast(grid_col), .y = @intCast(vp_row) },
+ }) orelse break :handoff;
+ const cursor_pin = screen.cursor.page_pin.*;
+ var prompts = cursor_pin.promptIterator(.left_up, null);
+ const prompt_pin = prompts.next() orelse break :handoff;
+ if (click_pin.before(prompt_pin)) break :handoff;
+ const moves = screen.promptClickMove(click_pin);
+ for (0..moves.left) |_| p.emitWrite(id, "\x1b[D");
+ for (0..moves.right) |_| p.emitWrite(id, "\x1b[C");
+ }
+
+ pane.mode = .tty;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.nsel = 0;
+ // A pinned row scrolls away. Raw mode must follow the program's live
+ // cursor, and Last must not restore a stale modal spot on the way back.
+ pane.cur_pinned = false;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ pane.pending = 0;
+}
+
+test "raw terminal keys encode text controls and special sequences" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const Case = struct { key: Key, expected: ?[]const u8 };
+ const cases = [_]Case{
+ .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" },
+ .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" },
+ .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" },
+ .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" },
+ .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" },
+ .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" },
+ .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" },
+ .{ .key = .{ .cp = Key.home }, .expected = null },
+ };
+ for (cases) |case| {
+ forwardKey(p, 0, case.key);
+ const expected = case.expected orelse {
+ try std.testing.expect(p.nextEffect() == null);
+ continue;
+ };
+ const effect = p.nextEffect() orelse return error.MissingWriteEffect;
+ switch (effect) {
+ .write => |write| {
+ try std.testing.expectEqual(@as(u8, 0), write.pane);
+ try std.testing.expectEqualStrings(expected, write.bytes.slice());
+ },
+ else => return error.UnexpectedEffect,
+ }
+ try std.testing.expect(p.nextEffect() == null);
+ }
+}
/// The memo behind `shellRows`. ONE entry for the editor, because the motion
/// surface is built for the pane the cursor is in and a second pane asking
@@ -81,7 +497,7 @@ const Rows = struct {
/// What LEAVING raw tty mode does to one prompt row, decided from its cells
/// alone. See config.tty_blank for why any of this happens.
-pub const PromptCut = union(enum) {
+const PromptCut = union(enum) {
/// show the row exactly as ghostty dumped it
keep,
/// show nothing at all
@@ -96,7 +512,7 @@ pub const PromptCut = union(enum) {
/// prompt). The row flag every caller tests first is only ghostty's "some cell
/// in here is a prompt cell" index; taking the row on that flag alone is what
/// used to throw the command away with the prompt.
-pub fn promptCut(pin: ghostty_vt.Pin) PromptCut {
+fn promptCut(pin: ghostty_vt.Pin) PromptCut {
if (config.tty_blank == .prompt_and_input) return .blank;
const cells = pin.cells(.all);
var cols: usize = 0;
@@ -118,7 +534,7 @@ pub fn promptCut(pin: ghostty_vt.Pin) PromptCut {
/// the single authority on how a cell spells itself — wide glyphs, combining
/// marks and all. One non-spacer cell is one dumped grapheme, and that is what
/// makes the cell walk and the byte walk stay in step.
-pub fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
+fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
const cols = switch (promptCut(pin)) {
.keep => return raw,
.blank => return "",
@@ -174,6 +590,188 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
return c.rows;
}
+/// The full modal motion surface: shell history with the live edit overlay
+/// spliced into the rows it covers.
+pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ const rows = try shellRows(p, pane);
+ if (pane.ovl == null) return rows;
+ var last = rows.len;
+ if (pane.ovl) |overlay|
+ last = @max(last, @as(usize, @intCast(@max(0, overlay.row + overlay.rows))));
+ var count = last;
+ if (pane.ovl) |overlay| {
+ if (overlay.row >= 0 and @as(usize, @intCast(overlay.row)) < last)
+ count = count - @min(
+ @as(usize, @intCast(overlay.rows)),
+ last - @as(usize, @intCast(overlay.row)),
+ ) + @max(1, modal.lineCount(overlay.text));
+ }
+ const lines = try p.scratch.allocator().alloc([]const u8, count);
+ var n: usize = 0;
+ var grid_row: usize = 0;
+ while (grid_row < last) : (grid_row += 1) {
+ if (pane.ovl) |overlay| if (overlay.row >= 0 and grid_row == @as(usize, @intCast(overlay.row))) {
+ var overlay_lines = std.mem.splitScalar(u8, overlay.text, '\n');
+ while (overlay_lines.next()) |line| : (n += 1) lines[n] = line;
+ grid_row += @intCast(overlay.rows - 1);
+ continue;
+ };
+ lines[n] = if (grid_row < rows.len) rows[grid_row] else "";
+ n += 1;
+ }
+ return lines[0..n];
+}
+
+/// Flatten `cursorLines` without rebuilding the common cached/no-overlay
+/// case. Scratch-owned when a join is required.
+pub fn flatSurface(p: *Pardes, pane: *Pane, lines: []const []const u8) ![]const u8 {
+ const cache = &p.shell_rows;
+ if (!cache.stale and cache.pane == pane and
+ lines.ptr == cache.rows.ptr and lines.len == cache.rows.len) return cache.text;
+ var total: usize = if (lines.len > 0) lines.len - 1 else 0;
+ for (lines) |line| total += line.len;
+ const text = try p.scratch.allocator().alloc(u8, total);
+ var at: usize = 0;
+ for (lines, 0..) |line, i| {
+ if (i > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..line.len], line);
+ at += line.len;
+ }
+ return text;
+}
+
+/// Materialize or extend the terminal edit overlay with one exact allocation.
+/// Row slices are scratch-owned/borrowed; only the joined text is installed.
+pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText {
+ const want_lo = @max(0, @min(lo, hi));
+ const want_hi = @max(want_lo, @max(lo, hi));
+ const fresh = pane.ovl == null;
+ const old: EditBuffer = pane.ovl orelse .{ .row = want_lo, .rows = 1, .text = &.{} };
+ const lines: i32 = if (fresh) 1 else @intCast(modal.lineCount(old.text));
+ const up = old.row - want_lo;
+ const down = want_hi - (old.row + lines - 1);
+ const extending = fresh or up > 0 or down > 0;
+ var row0 = old.row;
+ var covered = old.rows;
+ var text = old.text;
+ var owned = false;
+ if (extending) {
+ const rows = shellRows(p, pane) catch return null;
+ row0 = old.row - @max(0, up);
+ covered = old.rows + @max(0, up) + @max(0, down);
+ const up_len: usize = @intCast(@max(0, up));
+ const down_len: usize = @intCast(@max(0, down));
+ const parts = p.scratch.allocator().alloc([]const u8, up_len + 1 + down_len) catch return null;
+ for (parts[0..up_len], 0..) |*part, i| {
+ const src = @as(usize, @intCast(row0)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ const middle: usize = @intCast(old.row);
+ parts[up_len] = if (fresh)
+ (if (middle < rows.len) rows[middle] else "")
+ else
+ old.text;
+ for (parts[up_len + 1 ..], 0..) |*part, i| {
+ const src = @as(usize, @intCast(old.row + old.rows)) + i;
+ part.* = if (src < rows.len) rows[src] else "";
+ }
+ text = std.mem.join(p.gpa, "\n", parts) catch return null;
+ owned = true;
+ }
+
+ const row: usize = @intCast(@max(0, want_lo - row0));
+ const line_len: i32 = @intCast(modal.lineSlice(text, row).len);
+ if (col > line_len) {
+ const spaces = p.scratch.allocator().alloc(u8, @intCast(col - line_len)) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ @memset(spaces, ' ');
+ const padded = modal.insertAt(p.gpa, text, .{ .row = row, .col = @intCast(line_len) }, spaces) catch {
+ if (owned) p.gpa.free(text);
+ return null;
+ };
+ if (owned) p.gpa.free(text);
+ text = padded;
+ owned = true;
+ }
+ if (owned) {
+ if (pane.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ovl = .{ .row = row0, .rows = covered, .text = text };
+ }
+ return .{ .text = pane.ovl.?.text, .row0 = pane.ovl.?.row };
+}
+
+/// Consume a rewritten overlay, freeing the terminal edit text it replaces.
+pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void {
+ const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new);
+ p.gpa.free(overlay.text);
+ overlay.text = new;
+}
+
+/// Serialize terminal-only state; the core supplies shared pane metadata.
+pub fn dumpPane(
+ pane: *Pane,
+ arena: std.mem.Allocator,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize,
+) !dump.Pane {
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ const extra = if (pane.ovl) |overlay| overlay.text.len else 0;
+ const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra));
+ var len: usize = 0;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: i32 = 0;
+ var skip: i32 = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ // Hidden overlay rows still consume prompt pins to keep them aligned.
+ const prompt = if (pane.mode != .tty) prompts.next() else null;
+ if (skip > 0) {
+ skip -= 1;
+ continue;
+ }
+ if (row > 0) {
+ stream[len] = '\n';
+ len += 1;
+ }
+ if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) {
+ @memcpy(stream[len..][0..overlay.text.len], overlay.text);
+ len += overlay.text.len;
+ skip = overlay.rows - 1;
+ continue;
+ };
+ const shown = if (prompt) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
+ else
+ raw;
+ @memcpy(stream[len..][0..shown.len], shown);
+ len += shown.len;
+ }
+ return .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream[0..len],
+ .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)),
+ .cursor = .{
+ .col = pane.vt.screens.active.cursor.x,
+ .row = pane.vt.screens.active.cursor.y,
+ },
+ },
+ };
+}
+
fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
@@ -292,7 +890,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, off: i32) void {
const s = &p.surface;
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
- const body_y = if (p.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
var vr: u16 = 0;
while (vr < body_h and vr < pane.rows) : (vr += 1) {
const grid = pane.gridRow(off + @as(i32, vr)) - goff;
@@ -377,15 +975,15 @@ pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
}
/// the snapshot takes ownership of a COPY of the edit buffer's text
-pub fn snap(p: *Pardes, pane: *Pane) ?EditSnap {
- var ovl: ?Ovl = null;
+pub fn snap(p: *Pardes, pane: *Pane) ?Snapshot {
+ var ovl: ?EditBuffer = null;
if (pane.ovl) |o| ovl = .{ .row = o.row, .rows = o.rows, .text = p.gpa.dupe(u8, o.text) catch return null };
return .{ .ovl = ovl, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .vsel = pane.vsel };
}
/// undo/redo restores the selection recorded with the snapshot (helix
/// keeps selections in its history transactions)
-pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void {
+pub fn restoreSnap(p: *Pardes, pane: *Pane, s: Snapshot) void {
if (pane.ovl) |o| p.gpa.free(o.text);
pane.ovl = s.ovl;
pane.cur_row = s.cur_row;
@@ -396,12 +994,52 @@ pub fn restoreSnap(p: *Pardes, pane: *Pane, s: EditSnap) void {
pane.ensureCursorVisible();
}
+fn pushHistory(gpa: std.mem.Allocator, slots: []Snapshot, len: *usize, value: Snapshot) void {
+ if (len.* == slots.len) {
+ if (slots[0].ovl) |overlay| gpa.free(overlay.text);
+ std.mem.copyForwards(Snapshot, slots[0 .. slots.len - 1], slots[1..]);
+ len.* -= 1;
+ }
+ slots[len.*] = value;
+ len.* += 1;
+}
+
+pub fn pushUndo(p: *Pardes, pane: *Pane) void {
+ const current = pane.ovl orelse EditBuffer{ .rows = 0 };
+ if (pane.ed_undo_len > 0) {
+ const top = pane.ed_undo[pane.ed_undo_len - 1];
+ const same = if (top.ovl) |overlay| pane.ovl != null and overlay.row == current.row and
+ overlay.rows == current.rows and std.mem.eql(u8, overlay.text, current.text) else pane.ovl == null;
+ if (same) return;
+ }
+ const value = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, value);
+ for (pane.ed_redo[0..pane.ed_redo_len]) |item| if (item.ovl) |overlay| p.gpa.free(overlay.text);
+ pane.ed_redo_len = 0;
+}
+
+pub fn undo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_undo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_redo, &pane.ed_redo_len, current);
+ pane.ed_undo_len -= 1;
+ restoreSnap(p, pane, pane.ed_undo[pane.ed_undo_len]);
+}
+
+pub fn redo(p: *Pardes, pane: *Pane) void {
+ if (pane.ed_redo_len == 0) return;
+ const current = snap(p, pane) orelse return;
+ pushHistory(p.gpa, &pane.ed_undo, &pane.ed_undo_len, current);
+ pane.ed_redo_len -= 1;
+ restoreSnap(p, pane, pane.ed_redo[pane.ed_redo_len]);
+}
+
// ghostty calls this with a reply (cursor-position report, DA, ...) to send
// back to the child as if it typed it. The handler's `terminal` is our Pane.vt
// field; recover the Pane and stash the bytes — sync() drains them into write
// effects (the callback has no path to the effect queue).
pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const pane: *Pane = @fieldParentPtr("vt", handler.terminal);
+ const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal));
const room = pane.reply.len - pane.reply_len;
const n = @min(room, data.len);
@memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]);