summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 21:27:42 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitaf74ce84f8230adb45857f2a6364d96a6205e4c0 (patch)
tree3a23106e991465f6fc5cb6ba426c086bdcf2cd30
parent7d78a880201d25b8e7b9de1f7bd22199f92a2705 (diff)
downloadpardes-af74ce84f8230adb45857f2a6364d96a6205e4c0.tar.gz
pardes-af74ce84f8230adb45857f2a6364d96a6205e4c0.zip
Move the Terminal namespace out of panes.zig into Terminal.zig
Pure move: the body of `pub const Terminal = struct { ... }` is now the file Terminal.zig, dedented, with the imports it uses; panes.zig keeps `pub const Terminal = @import("Terminal.zig");`, so `panes.Terminal.x` callers and Pane's `Terminal.State` field are unchanged. Terminal.zig joins the embedded source list. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/Terminal.zig2539
-rw-r--r--src/fs.zig1
-rw-r--r--src/panes.zig2527
3 files changed, 2541 insertions, 2526 deletions
diff --git a/src/Terminal.zig b/src/Terminal.zig
new file mode 100644
index 00000000..a8588952
--- /dev/null
+++ b/src/Terminal.zig
@@ -0,0 +1,2539 @@
+//! A terminal pane: its VT emulator and replay, prompt rows, the edit overlay
+//! typed over shell rows, and the Filter that recolours output to the theme.
+const panes = @import("panes.zig");
+const std = @import("std");
+const pardes = @import("pardes.zig");
+const config = @import("config.zig");
+const Pardes = pardes.Pardes;
+const modal = @import("modal.zig");
+const tracy = @import("tracy.zig");
+const dump = @import("dump.zig");
+const Key = pardes.Key;
+const Pane = panes.Pane;
+const EditText = panes.EditText;
+const File = panes.File;
+
+pub const enabled = pardes.terminal_panes;
+const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
+
+pub const history_max = if (enabled) 64 else 8;
+
+pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
+pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void;
+
+/// The `aid` pardes's injected bash and fish marks carry (host_io.zig).
+pub const prompt_aid = "pardes";
+
+/// ghostty's handler, plus where the shell is in its command cycle as
+/// its OSC 133 marks tell it: ghostty parses D's exit status and drops
+/// it, and /pane/<n>/pty/run waits on exactly that. Only marks carrying
+/// pardes's own `aid` move it, so fish's native marks, a nested shell's
+/// and a stray `133;D` in some file being printed all pass it by.
+pub const MarkedHandler = if (enabled) struct {
+ inner: ghostty_vt.TerminalStream.Handler,
+ phase: enum { none, prompt, input, running } = .none,
+ /// Counts commands started (C at the input phase).
+ started: u32 = 0,
+ /// Counts prompts drawn (B), so a line the shell refused without
+ /// running (a fish syntax error: no C, no D, a new prompt) shows.
+ prompts: u32 = 0,
+ /// D marks outside a running command: the shell handled a line
+ /// without starting it (bash on a syntax error prints no C).
+ declined: u32 = 0,
+ /// The newest prompt continues an unfinished line (bash's PS2, marked
+ /// `k=c`): the shell still holds the start of the command.
+ continuation: bool = false,
+ /// Where the newest command's output began (its C) and ended (its
+ /// D) on the primary screen, pinned so scrolling keeps them.
+ out_start: ?*ghostty_vt.Pin = null,
+ out_end: ?*ghostty_vt.Pin = null,
+ /// The running command's output was erased under it -- a clear, ED 3,
+ /// a reset -- so the pins no longer span what it printed: ED 3 moves
+ /// a pin to the next page without flagging it `garbage`
+ /// (PageList.erasePage).
+ lost: bool = false,
+
+ /// The newest command to end: which one, and its status if given.
+ finished: ?struct { seq: u32, status: ?i32 } = null,
+
+ const Action = ghostty_vt.TerminalStream.Action;
+
+ /// Move one of the output pins to the cursor. Only the primary screen
+ /// is pinned: a command's own full-screen display is not its output.
+ fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void {
+ const screen = self.inner.terminal.screens.get(.primary) orelse return;
+ if (slot.*) |old| screen.pages.untrackPin(old);
+ slot.* = null;
+ if (!now or self.inner.terminal.screens.active_key != .primary) return;
+ slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null;
+ }
+
+ pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void {
+ if (comptime action == .semantic_prompt) mark: {
+ const aid = value.readOption(.aid) orelse break :mark;
+ if (!std.mem.eql(u8, aid, prompt_aid)) break :mark;
+ switch (value.action) {
+ .fresh_line_new_prompt => {
+ self.phase = .prompt;
+ self.continuation = value.readOption(.prompt_kind) == .continuation;
+ },
+ .end_prompt_start_input => {
+ self.phase = .input;
+ self.prompts +%= 1;
+ },
+ // bash's DEBUG trap also fires on its own PROMPT_COMMAND,
+ // outside the input phase; only a C that ends input counts
+ .end_input_start_output => if (self.phase == .input) {
+ self.phase = .running;
+ self.started +%= 1;
+ self.lost = false;
+ self.pinCursor(&self.out_start, true);
+ self.pinCursor(&self.out_end, false);
+ },
+ .end_command => if (self.phase == .running) {
+ self.phase = .none;
+ self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) };
+ self.pinCursor(&self.out_end, true);
+ } else {
+ self.declined +%= 1;
+ },
+ else => {},
+ }
+ }
+ if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) {
+ if (self.phase == .running) self.lost = true;
+ }
+ self.inner.vt(action, value);
+ }
+
+ pub fn deinit(self: *@This()) void {
+ self.inner.deinit();
+ }
+} else void;
+
+pub const Replay = struct {
+ bytes: [1024 * 1024]u8 = undefined,
+ head: usize = 0,
+ len: usize = 0,
+};
+
+pub const State = struct {
+ vt: VtSlot,
+ stream: StreamSlot,
+ replay: Replay,
+ reply: [256]u8 = undefined,
+ reply_len: u16 = 0,
+};
+
+const GColor = ghostty_vt.color;
+
+const FilterPaletteKey = struct {
+ bg: GColor.RGB,
+ fg: GColor.RGB,
+ base: [16]GColor.RGB,
+};
+
+pub const FilterPalette = if (enabled) LivePalette else struct {};
+
+const LivePalette = struct {
+ key: ?FilterPaletteKey = null,
+ colors: GColor.Palette = GColor.default,
+
+ fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette {
+ const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var base: [16]GColor.RGB = undefined;
+ if (theme.palette) |palette| {
+ for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
+ } else {
+ const synthesized = [16][3]u8{
+ theme.bg orelse theme.tag_bg,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.box,
+ theme.sel_bg,
+ theme.comment,
+ theme.fg orelse theme.tag_fg,
+ theme.lineno,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.scroll_thumb,
+ theme.sel_fg,
+ theme.tag_fg,
+ theme.fg orelse theme.tag_fg,
+ };
+ for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
+ }
+
+ const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
+ if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
+
+ var seed = GColor.default;
+ for (base, 0..) |rgb, i| seed[i] = rgb;
+ self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
+ self.key = key;
+ return &self.colors;
+ }
+};
+
+fn asGhostRgb(rgb: [3]u8) GColor.RGB {
+ return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
+}
+
+fn asPardesColor(rgb: GColor.RGB) pardes.Color {
+ return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+}
+
+/// 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: Pane.CharSel,
+};
+
+pub const PendingCommand = struct {
+ bytes: []u8 = &.{},
+ wait: enum { none, spawn, input } = .none,
+};
+
+pub fn armShellSpawn(pane: *Pane) void {
+ if (comptime !enabled) return;
+ std.debug.assert(pane.pending_command.bytes.len == 0);
+ pane.pending_command.wait = .spawn;
+}
+
+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;
+ pane.greet = false;
+ return true;
+}
+
+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 {
+ pane.greet = false;
+ releasePendingCommand(p, id, pane, true);
+ }
+}
+
+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 = .{};
+}
+
+pub fn terminalIo() std.Io {
+ return if (comptime !pardes.hosted)
+ std.Io.failing
+ else
+ std.Io.Threaded.global_single_threaded.io();
+}
+
+/// The three numbers ghostty's scrollbar reports; all zero without an emulator.
+pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 };
+
+pub fn scrollbar(pane: *const Pane) Scrollbar {
+ if (comptime !enabled) return .{};
+ const state = pane.terminal orelse return .{};
+ const sb = state.vt.screens.active.pages.scrollbar();
+ return .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
+}
+
+/// The emulator's viewport offset, in SHELL rows: the top of what it shows.
+pub fn gridOffset(pane: *const Pane) i32 {
+ return @intCast(scrollbar(pane).offset);
+}
+
+/// Where the emulator itself puts the cursor, in active-area cells — the origin
+/// without one, which is where an empty pane's cursor belongs anyway.
+pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 };
+
+pub fn gridCursor(pane: *const Pane) GridCursor {
+ if (comptime !enabled) return .{};
+ const state = pane.terminal orelse return .{};
+ const cur = state.vt.screens.active.cursor;
+ return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
+}
+
+pub fn visibleCursor(pane: *const Pane) ?GridCursor {
+ if (comptime !enabled) return null;
+ const state = pane.terminal orelse return null;
+ if (!state.vt.modes.get(.cursor_visible)) return null;
+ const cur = state.vt.screens.active.cursor;
+ const sb = scrollbar(pane);
+ const row = sb.total - sb.len + cur.y;
+ if (row < sb.offset or row - sb.offset >= sb.len) return null;
+ return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) };
+}
+
+/// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
+pub fn scrollGrid(pane: *Pane, delta: i32) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.vt.screens.active.scroll(.{ .delta_row = delta });
+}
+
+/// Is the viewport already showing the last row of output?
+pub fn atBottom(pane: *const Pane) bool {
+ if (comptime !enabled) return true;
+ const sb = scrollbar(pane);
+ return sb.offset + sb.len >= sb.total;
+}
+
+/// Snap the viewport back onto live output.
+pub fn followOutput(pane: *Pane) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.vt.screens.active.scroll(.active);
+}
+
+/// Reflow the grid. A failed reflow keeps the grid it had rather than dropping
+/// a scrollback; the next resize retries with the same numbers.
+pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ if (cols != pane.cols) for (0..pane.sel.len) |button| {
+ pane.clearPointerSelection(button);
+ pane.sel[button].state = .none;
+ };
+ state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {};
+}
+
+/// DECSET 2004: the program wants its pastes bracketed.
+pub fn bracketedPaste(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.bracketed_paste);
+}
+
+/// The program tracks the mouse itself, so a click in its body is its event.
+pub fn reportsMouse(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ const m = &state.vt.modes;
+ return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
+}
+
+/// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
+pub fn mouseFormatSgr(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.modes.get(.mouse_format_sgr);
+}
+
+/// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
+pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
+ if (comptime !enabled) return &.{};
+ const state = pane.terminal orelse return &.{};
+ return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
+}
+
+/// Release the emulator's heap. The Pane allocation itself is the core's.
+pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ state.stream.deinit();
+ state.vt.deinit(gpa);
+ gpa.destroy(state);
+ pane.terminal = null;
+}
+
+/// 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 createDoc(gpa, cols, rows);
+ errdefer gpa.destroy(pane);
+ if (comptime !enabled) return pane;
+ const state = try gpa.create(State);
+ errdefer gpa.destroy(state);
+ state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 16 * 1024 * 1024,
+ });
+ state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() });
+ state.replay.head = 0;
+ state.replay.len = 0;
+ state.reply_len = 0;
+ state.stream.handler.inner.effects.write_pty = ptyReport;
+ state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs;
+ pane.terminal = state;
+ pane.tty_filter = true;
+ return pane;
+}
+
+pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
+ const pane = try gpa.create(Pane);
+ pane.* = .{
+ .gpa = gpa,
+ .cols = cols,
+ .rows = rows,
+ };
+ return pane;
+}
+
+/// Where a restored terminal's history ends and its new shell begins.
+pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n";
+
+/// Rebuild a dump's terminal emulator from its recorded output. `live`: a
+/// shell will be spawned into it, so the history is marked off from what
+/// that shell prints and the pane waits for it like any new terminal.
+/// 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, live: bool) !*Pane {
+ const terminal = src.terminal.?;
+ if (comptime !enabled) {
+ const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
+ errdefer p.gpa.destroy(pane);
+ if (terminal.stream.len > 0)
+ pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
+ return pane;
+ }
+ // An older dump's raw output tail cannot come back live: cut off mid
+ // redraw, or written at sizes the pane no longer has, it replays as
+ // garbage. Its rendered text can.
+ const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0;
+ const bytes = if (legacy_text)
+ try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n")
+ else 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 (live) {
+ if (bytes.len > 0) {
+ ingest(pane, bytes);
+ ingest(pane, restored_banner);
+ }
+ followOutput(pane);
+ // A view left scrolled back stays on what it was showing: the
+ // banner's line breaks scrolled a full screen by that many more.
+ if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n"))));
+ armShellSpawn(pane);
+ } else if (bytes.len > 0) {
+ ingest(pane, bytes);
+ followOutput(pane);
+ if (src.scroll > 0)
+ scrollGrid(pane, -@as(i32, @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 {
+ const state = pane.terminal.?;
+ const replay = &state.replay;
+ if (bytes.len >= replay.bytes.len) {
+ const kept = bytes[bytes.len - replay.bytes.len ..];
+ @memcpy(&replay.bytes, kept);
+ replay.head = 0;
+ replay.len = replay.bytes.len;
+ } else {
+ const overflow = bytes.len -| (replay.bytes.len - replay.len);
+ replay.head = (replay.head + overflow) % replay.bytes.len;
+ replay.len -= overflow;
+ const tail = (replay.head + replay.len) % replay.bytes.len;
+ const first = @min(bytes.len, replay.bytes.len - tail);
+ @memcpy(replay.bytes[tail..][0..first], bytes[0..first]);
+ @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]);
+ replay.len += bytes.len;
+ }
+ state.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 {
+ const state = pane.terminal orelse return &.{};
+ const replay = &state.replay;
+ if (replay.len == 0) return &.{};
+ if (replay.head + replay.len <= replay.bytes.len)
+ return replay.bytes[replay.head..][0..replay.len];
+ const out = try allocator.alloc(u8, replay.len);
+ const first = replay.bytes.len - replay.head;
+ @memcpy(out[0..first], replay.bytes[replay.head..]);
+ @memcpy(out[first..], replay.bytes[0 .. replay.len - first]);
+ return out;
+}
+
+test "replay ownership is terminal-only and construction rolls back on allocation failure" {
+ const Case = struct {
+ fn run(gpa: std.mem.Allocator, terminal: bool) !void {
+ const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12);
+ defer gpa.destroy(pane);
+ defer deinitEmulator(pane, gpa);
+ try std.testing.expectEqual(terminal and enabled, pane.terminal != null);
+ }
+ };
+ try std.testing.expect(@sizeOf(Pane) < 128 * 1024);
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false});
+ try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true});
+}
+
+test "document construction allocates only the pane and has inert terminal defaults" {
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
+ const allocator = failing.allocator();
+ const pane = try createDoc(allocator, 40, 12);
+ defer allocator.destroy(pane);
+ defer deinitEmulator(pane, allocator);
+ try std.testing.expect(pane.terminal == null);
+ try std.testing.expectEqual(@as(usize, 1), failing.alloc_index);
+ try std.testing.expectEqual(@as(usize, 0), failing.resize_index);
+ try std.testing.expect(!failing.has_induced_failure);
+ try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes);
+ try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
+ try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
+ try std.testing.expect(!bracketedPaste(pane));
+ try std.testing.expect(!reportsMouse(pane));
+ try std.testing.expect(!mouseFormatSgr(pane));
+ try std.testing.expect(!promptInputReady(pane));
+ scrollGrid(pane, 100);
+ followOutput(pane);
+ resizeGrid(pane, allocator, 80, 24);
+ try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
+ try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
+ try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator));
+ try std.testing.expectEqualStrings("", try replayBytes(pane, allocator));
+ try std.testing.expect(!failing.has_induced_failure);
+}
+
+test "terminal state keeps parser fragments and sends emulator replies through its owner" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ const state = pane.terminal.?;
+ try std.testing.expect(state.stream.handler.inner.terminal == &state.vt);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } });
+ while (p.nextEffect()) |effect| {
+ if (effect == .write) return error.PrematureTerminalReply;
+ }
+ p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } });
+ var replies: usize = 0;
+ while (p.nextEffect()) |effect| {
+ if (effect != .write) continue;
+ try std.testing.expectEqual(@as(u8, 0), effect.write.pane);
+ try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice());
+ replies += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), replies);
+ try std.testing.expectEqual(@as(u16, 0), state.reply_len);
+ try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator));
+}
+
+test "replay keeps a bounded chronological suffix across large writes and wrapping" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const pane = try create(gpa, 40, 12);
+ defer gpa.destroy(pane);
+ defer deinitEmulator(pane, gpa);
+ try std.testing.expectEqualStrings("", try replayBytes(pane, gpa));
+ ingest(pane, "hello");
+ const small = try replayBytes(pane, gpa);
+ try std.testing.expectEqualStrings("hello", small);
+ try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr));
+
+ const capacity = pane.terminal.?.replay.bytes.len;
+ const input = try gpa.alloc(u8, capacity + 7);
+ defer gpa.free(input);
+ @memset(input, 0);
+ const ending = "\x1b[31mred\x1b[0m\r\n";
+ @memcpy(input[input.len - ending.len ..], ending);
+ ingest(pane, input);
+ try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa));
+ ingest(pane, "next\r\n");
+ const wrapped = try replayBytes(pane, gpa);
+ defer gpa.free(wrapped);
+ try std.testing.expectEqual(capacity, wrapped.len);
+ try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]);
+ try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]);
+}
+
+test "replay restores terminal cells from the retained stream" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n");
+ const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa));
+ defer gpa.free(encoded);
+ const restored = try restore(p, .{
+ .kind = .terminal,
+ .tag = "",
+ .body = "",
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .terminal = .{ .stream_b64 = encoded },
+ }, false);
+ defer gpa.destroy(restored);
+ defer deinitEmulator(restored, gpa);
+ const before = try screenTextAlloc(pane, gpa);
+ defer gpa.free(before);
+ const after = try screenTextAlloc(restored, gpa);
+ defer gpa.free(after);
+ try std.testing.expectEqualStrings(before, after);
+ try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
+}
+
+test "a restored terminal keeps its history above a marker and gets a shell where it was" {
+ if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ feedOutput(p, pane, "old output\r\n$ ls\r\n");
+ try pane.setOwnedCwd("/tmp");
+ try p.dumpState();
+ const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
+ defer restored.deinit();
+ var spawned = false;
+ while (restored.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"),
+ else => {},
+ };
+ try std.testing.expect(spawned);
+ const again = restored.panes[0].?;
+ try std.testing.expectEqual(.spawn, again.pending_command.wait);
+ const text = try screenTextAlloc(again, gpa);
+ defer gpa.free(text);
+ const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost;
+ const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker;
+ try std.testing.expect(old_at < mark_at);
+}
+
+test "an older dump comes back live from its rendered text, not its raw output tail" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ // A tail cut mid-redraw: relative moves from a cursor it never set.
+ const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore");
+ defer gpa.free(tail);
+ const restored = try restore(p, .{
+ .kind = .terminal,
+ .tag = "",
+ .body = "",
+ .cols = 40,
+ .rows = 12,
+ .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail },
+ }, true);
+ defer gpa.destroy(restored);
+ defer deinitEmulator(restored, gpa);
+ defer deinitPendingCommand(restored);
+ const text = try screenTextAlloc(restored, gpa);
+ defer gpa.free(text);
+ try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null);
+ try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null);
+}
+
+/// 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 {
+ // There are no pty reads at all without an emulator to parse them into.
+ if (comptime !enabled) return;
+ if (pane.terminal == null) return;
+ const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or
+ pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or
+ pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0;
+ // Sticky bottom, as every other terminal does it: output drags the
+ // viewport down only when the viewport was already on the last row, so
+ // a reader scrolled back into the scrollback stays where they scrolled.
+ const at_bottom = atBottom(pane);
+ if (has_positions) ingestWithPositions(pane, bytes) else 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 and at_bottom) followOutput(pane);
+}
+
+const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 };
+const PositionPin = struct {
+ target: *i32,
+ row: RowPin,
+ overlay: ?*EditBuffer,
+ edit_row: ?i32 = null,
+};
+
+fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin {
+ if (row < 0) return .{ .offset = row };
+ const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1);
+ var left = pages.total_rows - 1 - bounded;
+ var node = pages.pages.last.?;
+ while (left >= node.rows()) {
+ left -= node.rows();
+ node = node.prev.?;
+ }
+ const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch {
+ failed.* = true;
+ return .{};
+ };
+ return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) };
+}
+
+fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 {
+ const pin = row.pin orelse return row.offset;
+ const alive = pages orelse return 0;
+ if (pin.garbage) return 0;
+ var after: usize = 0;
+ var node = alive.pages.last;
+ while (node) |item| : (node = item.prev) {
+ if (item == pin.node)
+ return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset;
+ after += item.rows();
+ }
+ return 0;
+}
+
+fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin {
+ var grid = target.*;
+ if (overlay) |edit| {
+ const lines: i32 = @intCast(modal.lineCount(edit.text));
+ if (grid >= edit.row and grid < edit.row +| lines)
+ return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row };
+ if (grid > edit.row) grid = grid -| lines +| edit.rows;
+ }
+ return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay };
+}
+
+fn pointerRowCount(pane: *const Pane) usize {
+ var count: usize = 0;
+ for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
+ count += rows.len;
+ };
+ return count;
+}
+
+fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
+ const screens = &pane.terminal.?.vt.screens;
+ const key = screens.active_key;
+ const generation = screens.generation(key);
+ const pages = &screens.active.pages;
+ const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel };
+ var groups: [history_max * 2 + 1]Group = undefined;
+ groups[0] = .{
+ .overlay = if (pane.ovl) |*overlay| overlay else null,
+ .cursor = &pane.cur_row,
+ .selection = &pane.vsel,
+ };
+ var ngroups: usize = 1;
+ for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| {
+ for (history) |*snapshot| {
+ groups[ngroups] = .{
+ .overlay = if (snapshot.ovl) |*overlay| overlay else null,
+ .cursor = &snapshot.cur_row,
+ .selection = &snapshot.vsel,
+ };
+ ngroups += 1;
+ }
+ }
+ const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin };
+ var spans: [groups.len]Span = undefined;
+ var nspans: usize = 0;
+ var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined;
+ var npositions: usize = 0;
+ var failed = false;
+ for (groups[0..ngroups]) |group| {
+ if (group.overlay) |overlay| {
+ spans[nspans] = .{
+ .overlay = overlay,
+ .start = trackRow(pages, overlay.row, &failed),
+ .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed),
+ };
+ nspans += 1;
+ }
+ positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed);
+ npositions += 1;
+ if (group.selection.active) {
+ positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed);
+ npositions += 1;
+ }
+ }
+ const overlay = groups[0].overlay;
+ var extra: [Pane.max_selections * 2 + 4]*i32 = undefined;
+ var nextra: usize = 0;
+ if (pane.msel.active) {
+ extra[nextra] = &pane.msel.r0;
+ extra[nextra + 1] = &pane.msel.r1;
+ nextra += 2;
+ }
+ for (pane.sels[0..pane.nsel]) |*selection| {
+ extra[nextra] = &selection.row;
+ extra[nextra + 1] = &selection.arow;
+ nextra += 2;
+ }
+ if (pane.append_at) |*at| {
+ extra[nextra] = &at.row;
+ nextra += 1;
+ }
+ if (pane.look_at) |*at| {
+ extra[nextra] = &at.row;
+ nextra += 1;
+ }
+ for (extra[0..nextra]) |target| {
+ positions[npositions] = trackPosition(pages, target, overlay, &failed);
+ npositions += 1;
+ }
+ const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null;
+ defer if (pointer_positions) |pins| pane.gpa.free(pins);
+ if (pointer_positions) |pins| {
+ var index: usize = 0;
+ for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
+ for (rows) |*row| {
+ pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed);
+ index += 1;
+ }
+ };
+ } else failed = true;
+ ingest(pane, bytes);
+ // RIS can destroy the alternate screen and every pin it owned.
+ const alive = if (screens.generation(key) == generation) pages else null;
+ var moved = false;
+ var pointer_rows_lost = alive == null or pointer_positions == null;
+ if (pointer_positions) |pins| for (pins) |position| {
+ if (alive != null) if (position.row.pin) |pin| {
+ if (pin.garbage) pointer_rows_lost = true;
+ };
+ };
+ for (spans[0..nspans]) |span| {
+ const start = pinnedRow(alive, span.start);
+ const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1);
+ moved = moved or start != span.overlay.row or rows != span.overlay.rows;
+ span.overlay.row = start;
+ span.overlay.rows = rows;
+ if (alive) |owner| {
+ if (span.start.pin) |pin| owner.untrackPin(pin);
+ if (span.end.pin) |pin| owner.untrackPin(pin);
+ }
+ }
+ for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| {
+ var row = pinnedRow(alive, position.row);
+ if (position.overlay) |edit| {
+ if (position.edit_row) |relative| {
+ row = edit.row +| relative;
+ } else if (row > edit.row) {
+ row = if (row < edit.row +| edit.rows)
+ edit.row
+ else
+ row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text)));
+ }
+ }
+ moved = moved or row != position.target.*;
+ position.target.* = row;
+ if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin);
+ };
+ if (pointer_rows_lost) for (0..pane.sel.len) |button| {
+ pane.clearPointerSelection(button);
+ pane.sel[button].state = .none;
+ };
+ if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text.
+ if (failed) {
+ const message = "terminal edit position reset: out of memory";
+ @memcpy(pane.msg[0..message.len], message);
+ pane.msg_len = message.len;
+ }
+}
+
+test "raw terminal output needs no position tracking allocations" {
+ if (comptime !enabled) return error.SkipZigTest;
+ var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
+ const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ pane.mode = .tty;
+ feedOutput(p, pane, "warm");
+ const allocations = failing.alloc_index;
+ failing.fail_index = allocations;
+ const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins();
+ feedOutput(p, pane, " output");
+ try std.testing.expectEqual(allocations, failing.alloc_index);
+ try std.testing.expect(!failing.has_induced_failure);
+ try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins());
+}
+
+pub fn promptInputReady(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ const state = pane.terminal orelse return false;
+ return state.vt.screens.active_key != .alternate and
+ state.vt.screens.active.cursor.semantic_content == .input;
+}
+
+pub const CommandOutput = struct {
+ /// Owned by the caller.
+ text: [:0]const u8,
+ /// Whether `text` starts where the command did. It does not when that
+ /// start is not there to read: cut off by `max_rows`, scrolled out of
+ /// the history (ghostty moved the pin to the oldest row it kept and
+ /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or
+ /// never pinned (a C or D that came on the alternate screen).
+ whole: bool,
+};
+
+/// What the newest finished command printed: the screen text between its
+/// C and D marks, read from at most `max_rows` rows above its end, so a
+/// command that printed the whole history costs no more than its tail.
+pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
+ // Unless there is text to read, whether the nothing it printed is the
+ // whole of it.
+ const whole = if (comptime enabled) printed: {
+ const state = pane.terminal orelse break :printed true;
+ const marks = &state.stream.handler;
+ const start = marks.out_start orelse break :printed false;
+ const end = marks.out_end orelse break :printed false;
+ const screen = state.vt.screens.get(.primary) orelse break :printed false;
+ if (end.garbage) break :printed false;
+ var whole = !marks.lost and !start.garbage;
+ // D is where the next prompt begins: the output ends a cell before.
+ const last = end.leftWrap(1) orelse break :printed whole;
+ if (start.eql(end.*) or last.before(start.*)) break :printed whole;
+ var from = start.*;
+ if (last.up(max_rows)) |floor| if (from.before(floor)) {
+ from = floor;
+ from.x = 0;
+ whole = false;
+ };
+ return .{
+ .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }),
+ .whole = whole,
+ };
+ } else true;
+ return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole };
+}
+
+/// At a prompt with nothing typed on its line: no input left of the
+/// cursor and none under it (text typed and then Ctrl-A'd starts at the
+/// cursor). Nothing further right counts: that is where fish draws a
+/// right prompt, and fish suggests nothing on an empty line.
+pub fn promptInputEmpty(pane: *const Pane) bool {
+ if (comptime !enabled) return false;
+ if (!promptInputReady(pane)) return false;
+ const cursor = &pane.terminal.?.vt.screens.active.cursor;
+ const row = cursor.page_pin.cells(.all);
+ for (row[0..@min(cursor.x + 1, row.len)]) |cell|
+ if (cell.semantic_content == .input and !cell.isEmpty()) return false;
+ return true;
+}
+
+/// Encode one key for the program that owns a raw terminal and queue its pty
+/// write, respecting the application's keyboard protocol and terminal modes.
+pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ if (comptime enabled) {
+ const pane = p.panes[id] orelse return;
+ const state = pane.terminal orelse return;
+ const input = ghostty_vt.input;
+ const physical: input.Key = switch (key.cp) {
+ Key.enter => .enter,
+ Key.backspace => .backspace,
+ Key.tab => .tab,
+ Key.escape => .escape,
+ Key.up => .arrow_up,
+ Key.down => .arrow_down,
+ Key.left => .arrow_left,
+ Key.right => .arrow_right,
+ Key.home => .home,
+ Key.end => .end,
+ Key.page_up => .page_up,
+ Key.page_down => .page_down,
+ Key.delete => .delete,
+ else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified,
+ };
+ var utf8: [4]u8 = undefined;
+ const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp;
+ const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace)
+ utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return]
+ else
+ "";
+ var buffer: [256]u8 = undefined;
+ var writer = std.Io.Writer.fixed(&buffer);
+ input.encodeKey(&writer, .{
+ .key = physical,
+ .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift },
+ .consumed_mods = .{ .shift = key.text.len > 0 and key.shift },
+ .utf8 = text,
+ .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0,
+ }, options: {
+ var opts = input.KeyEncodeOptions.fromTerminal(&state.vt);
+ // fromTerminal cannot know this one and says so, defaulting to
+ // "option is not alt" — right for an app that lets macOS do the
+ // unicode translation and hands the encoder the composed text.
+ // Every pardes shell resolves the modifier itself and hands the
+ // core the BASE key with the alt bit and no text at all (see
+ // PardesView.keyDown, and app.mjs doing the same with the same
+ // two bits), so by the time this runs alt already means alt.
+ // Left at the default, a macOS build drops the ESC prefix on
+ // the floor and forwards Alt-n to the child as a bare `n`.
+ opts.macos_option_as_alt = .true;
+ break :options opts;
+ }) catch return;
+ if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } });
+ return;
+ }
+ 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) } });
+}
+
+pub fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null;
+ if (comptime enabled) if (!selected and pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: {
+ const screen = pane.terminal.?.vt.screens.active;
+ 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;
+ followOutput(pane); // entering raw mode is a request for the live shell
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.nsel = 0;
+ // The raw renderer follows the live cursor. A captured mouse selection
+ // keeps its modal endpoint for the next return to editor mode.
+ pane.cur_pinned = selected;
+ pane.select = false;
+ pane.append_at = null;
+ pane.sticky_col = -1;
+ pane.normal.clear();
+}
+
+// Returned slices remain valid until the next cache rebuild or reset.
+pub const RowsCache = struct {
+ /// whose grid this describes; null = the slot is free
+ pane: ?*const Pane = null,
+ /// the rows joined by '\n' — `flatSurface` hands this back verbatim
+ /// instead of rebuilding the join on every keystroke
+ text: []const u8 = &.{},
+ /// slices INTO `text`, absolute grid rows from 0
+ rows: [][]const u8 = &.{},
+ /// `text` is a prefix of this: blanking a prompt row shortens the join,
+ /// and the slack is not worth a second allocation to reclaim
+ text_alloc: []u8 = &.{},
+ stale: bool = false,
+
+ pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
+ if (c.rows.len > 0) gpa.free(c.rows);
+ c.* = .{};
+ }
+
+ /// Free a stale entry. Called at the top of `update`, and nowhere else.
+ pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
+ if (c.stale) c.reset(gpa);
+ }
+
+ /// `pane`'s grid moved: the entry no longer describes it.
+ pub fn markStale(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane == pane) c.stale = true;
+ }
+
+ pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
+ if (c.pane != pane) return;
+ c.pane = null;
+ c.stale = true;
+ }
+};
+
+const Rows = struct {
+ text_alloc: []u8,
+ text: []const u8,
+ rows: [][]const u8,
+};
+
+const empty_grid = [1][]const u8{""};
+
+/// 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.
+const PromptCut = union(enum) {
+ /// show the row exactly as ghostty dumped it
+ keep,
+ /// show nothing at all
+ blank,
+ /// drop this many leading COLUMNS — the prompt — and keep the rest, which
+ /// is what was typed at it
+ cut: usize,
+};
+
+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;
+ while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1;
+ // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or
+ // a repaint that has moved on. Nothing here is the prompt, so hide nothing.
+ if (cols == 0) return .keep;
+ // ...and all prompt, nothing typed yet: the row is chrome end to end.
+ if (cols >= cells.len) return .blank;
+ return .{ .cut = cols };
+}
+
+pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize {
+ if (comptime !enabled) return 0;
+ if (row < 0) return 0;
+ const state = pane.terminal orelse return 0;
+ const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0;
+ if (pin.rowAndCell().row.semantic_prompt == .none) return 0;
+ return switch (promptCut(pin)) {
+ .keep => 0,
+ .blank => raw.len,
+ .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)),
+ };
+}
+
+fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
+ const cols = switch (promptCut(pin)) {
+ .keep => return raw,
+ .blank => return "",
+ .cut => |n| n,
+ };
+ const cells = pin.cells(.all);
+ var at: usize = 0;
+ var col: usize = 0;
+ while (col < cols and at < raw.len) {
+ const cell = &cells[col];
+ at = @min(raw.len, at + dumpedBytes(pin, cell));
+ // the tail cell of a wide glyph spells nothing of its own
+ col += if (cell.wide == .wide) @as(usize, 2) else 1;
+ }
+ return std.mem.trimEnd(u8, raw[at..], " \t");
+}
+
+fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize {
+ switch (cell.wide) {
+ .spacer_head, .spacer_tail => return 0,
+ .narrow, .wide => {},
+ }
+ var n: usize = switch (cell.content_tag) {
+ .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength(
+ cell.codepoint(),
+ ) catch 1,
+ // A cell carrying only a colour still spells one blank in the dump.
+ else => 1,
+ };
+ if (cell.content_tag == .codepoint_grapheme) {
+ if (pin.grapheme(cell)) |extra| for (extra) |cp| {
+ n += std.unicode.utf8CodepointSequenceLength(cp) catch 1;
+ };
+ }
+ return n;
+}
+
+pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
+ if (comptime !enabled) return &empty_grid;
+ if (pane.terminal == null) return &empty_grid;
+ const c = &p.shell_rows;
+ if (!c.stale and c.pane == pane) return c.rows;
+ if (c.pane != null) {
+ c.stale = true;
+ return (try buildRows(p.scratch.allocator(), p, pane)).rows;
+ }
+ const built = try buildRows(p.gpa, p, pane);
+ c.* = .{
+ .pane = pane,
+ .text = built.text,
+ .rows = built.rows,
+ .text_alloc = built.text_alloc,
+ };
+ 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);
+ for (0..pane.sel.len) |button| {
+ pane.clearPointerSelection(button);
+ pane.sel[button].state = .none;
+ }
+ p.gpa.free(overlay.text);
+ overlay.text = new;
+}
+
+/// The shell's screen and scrollback (the primary screen, even under a
+/// full-screen program) as VT that replays to what was shown: styled
+/// text, soft-wrapped lines rejoined so they reflow at another width.
+fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 {
+ const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{};
+ var out: std.Io.Writer.Allocating = .init(arena);
+ try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer);
+ return out.written();
+}
+
+/// 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 {
+ if (!enabled or pane.terminal == null) {
+ const text = if (pane.ovl) |overlay| overlay.text else "";
+ return .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .collapsed = pane.collapsed,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = try arena.dupe(u8, text),
+ .stream_b64 = &.{},
+ .cursor = .{ .col = 0, .row = 0 },
+ },
+ };
+ }
+ const full = try pane.terminal.?.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.terminal.?.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,
+ .collapsed = pane.collapsed,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream[0..len],
+ .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
+ .stream_is_screen = true,
+ .cursor = .{
+ .col = pane.terminal.?.vt.screens.active.cursor.x,
+ .row = pane.terminal.?.vt.screens.active.cursor.y,
+ },
+ },
+ };
+}
+
+fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
+ const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
+ var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ // split yields one more item than delimiters; the extra final slot is the
+ // cursor row retained below.
+ const n_rows = std.mem.count(u8, full, "\n") + 2;
+ const rows = try alloc.alloc([]const u8, n_rows);
+ errdefer alloc.free(rows);
+ // Blanking a prompt row only ever SHORTENS it and the retained cursor row
+ // adds one separator, so the dump's length plus one bounds the join.
+ const text = try alloc.alloc(u8, full.len + 1);
+ errdefer alloc.free(text);
+ var at: usize = 0;
+ var n: usize = 0;
+ var it = std.mem.splitScalar(u8, full, '\n');
+ while (it.next()) |raw| {
+ const shown = if (pit.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
+ else
+ raw;
+ if (n > 0) {
+ text[at] = '\n';
+ at += 1;
+ }
+ @memcpy(text[at..][0..shown.len], shown);
+ rows[n] = text[at..][0..shown.len];
+ at += shown.len;
+ n += 1;
+ }
+ text[at] = '\n';
+ at += 1;
+ rows[n] = text[at..][0..0];
+ n += 1;
+ std.debug.assert(n == n_rows);
+ return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
+}
+
+pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
+ const state = pane.terminal orelse break :vp &.{};
+ const screen = state.vt.screens.active;
+ var tl = screen.pages.getTopLeft(.viewport);
+ tl.x = 0;
+ const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint;
+ var rows_out: std.Io.Writer.Allocating = .init(arena);
+ try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false });
+ const raw = try rows_out.toOwnedSlice();
+ var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ var n: usize = 0;
+ while (lines.next()) |ln| {
+ vp[n] = if (pane.mode != .tty)
+ if (prompts.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln
+ else
+ ln
+ else
+ ln;
+ n += 1;
+ }
+ std.debug.assert(n == vp.len);
+ break :vp vp;
+ };
+
+ const len = fillBody(null, pane, vp);
+ const out = try arena.alloc(u8, len);
+ const filled = fillBody(out, pane, vp);
+ std.debug.assert(filled == out.len);
+ return out;
+}
+
+pub const BodyRow = union(enum) {
+ grid: i32,
+ edit: struct { line: []const u8, idx: usize },
+};
+
+const BodyWalk = struct {
+ pane: *Pane,
+ goff: i32,
+ g: i32,
+ /// the buffer can start above the viewport: drop the lines scrolled past
+ skip: usize,
+ n: usize = 0,
+ lines: ?std.mem.SplitIterator(u8, .scalar) = null,
+ covered: i32 = 0,
+ line_idx: usize = 0,
+
+ fn init(pane: *Pane) BodyWalk {
+ const off = pane.scroll();
+ const goff = gridOffset(pane);
+ return .{
+ .pane = pane,
+ .goff = goff,
+ .g = if (pane.mode == .tty) goff else pane.gridRow(off),
+ .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0,
+ };
+ }
+
+ fn next(w: *BodyWalk) ?BodyRow {
+ while (w.n < w.pane.rows) {
+ if (w.lines) |*it| {
+ if (it.next()) |line| {
+ const idx = w.line_idx;
+ w.line_idx += 1;
+ // Lines scrolled off the top still count: the index names a
+ // line of the BUFFER, not of the visible body.
+ if (w.skip > 0) {
+ w.skip -= 1;
+ continue;
+ }
+ w.n += 1;
+ return .{ .edit = .{ .line = line, .idx = idx } };
+ }
+ // The buffer stands in for `rows` shell rows however many lines
+ // it actually spelled, which is the whole slide.
+ w.g += w.covered;
+ w.skip = 0;
+ w.lines = null;
+ continue;
+ }
+ if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) {
+ w.lines = std.mem.splitScalar(u8, o.text, '\n');
+ w.covered = o.rows;
+ w.line_idx = 0;
+ continue;
+ };
+ const vi = w.g - w.goff;
+ w.g += 1;
+ w.n += 1;
+ return .{ .grid = vi };
+ }
+ return null;
+ }
+};
+
+/// Run the terminal body row walk. A null destination counts bytes; a slice
+/// fills the exact allocation made from that count.
+fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
+ var walk: BodyWalk = .init(pane);
+ var written: usize = 0;
+ var first = true;
+ while (walk.next()) |row| {
+ if (!first) {
+ if (dst) |out| out[written] = '\n';
+ written += 1;
+ }
+ first = false;
+ const bytes = switch (row) {
+ .edit => |e| e.line,
+ .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len)
+ viewport[@intCast(vi)]
+ else
+ "",
+ };
+ if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes);
+ written += bytes.len;
+ }
+ return written;
+}
+
+// Match surviving edit-buffer rows to their original terminal styles.
+const EditAnchors = struct {
+ /// the buffer's own text, walked in order: a line the VIEWPORT skipped still
+ /// consumes the row it came from, so the lines below it stay aligned
+ text: []const u8 = &.{},
+ at: usize = 0,
+ shell: []const []const u8 = &.{},
+ /// the covered span, absolute grid rows, as `[first, end)`
+ first: usize = 0,
+ end: usize = 0,
+ lines: usize = 0,
+ /// the line `at` names, and the first row still unclaimed
+ idx: usize = 0,
+ cursor: usize = 0,
+ budget: usize = 0,
+ active: bool = false,
+
+ fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors {
+ if (o.rows <= 0 or o.row < 0) return .{};
+ const first: usize = @intCast(o.row);
+ const screen = pane.terminal.?.vt.screens.active;
+ const total = screen.pages.scrollbar().total;
+ if (first >= total) return .{};
+ const covered: usize = @intCast(o.rows);
+ const count = @min(covered, total - first);
+ const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{};
+ const br = screen.pages.pin(.{ .screen = .{
+ .x = screen.pages.cols - 1,
+ .y = @intCast(first + count - 1),
+ } }) orelse return .{};
+ const arena = p.scratch.allocator();
+ var output: std.Io.Writer.Allocating = .init(arena);
+ screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{};
+ const shell = arena.alloc([]const u8, count) catch return .{};
+ var raw = std.mem.splitScalar(u8, output.written(), '\n');
+ var pins = tl.rowIterator(.right_down, br);
+ for (shell) |*row| {
+ const text = raw.next() orelse "";
+ row.* = if (pins.next()) |pin|
+ if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text
+ else
+ text;
+ }
+ const lines = std.mem.count(u8, o.text, "\n") + 1;
+ return .{
+ .text = o.text,
+ .shell = shell,
+ .first = first,
+ .end = first + count,
+ .lines = lines,
+ .cursor = first,
+ .budget = covered + 4 * lines,
+ .active = true,
+ };
+ }
+
+ fn shellRow(a: *EditAnchors, idx: usize) ?Anchor {
+ if (!a.active or idx >= a.lines) return null;
+ var found: ?Anchor = null;
+ while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null);
+ return found;
+ }
+
+ fn nextLine(a: *EditAnchors) ?[]const u8 {
+ if (a.at > a.text.len) return null;
+ const rest = a.text[a.at..];
+ if (std.mem.indexOfScalar(u8, rest, '\n')) |n| {
+ a.at += n + 1;
+ return rest[0..n];
+ }
+ // The last line has no terminator; one past the end ends the walk.
+ a.at = a.text.len + 1;
+ return rest;
+ }
+
+ /// Where this line still stands over the grid, if anywhere.
+ fn claim(a: *EditAnchors, line: []const u8) ?Anchor {
+ const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end;
+ var k = a.cursor;
+ while (k < end) : (k += 1) {
+ if (a.budget == 0) return null;
+ a.budget -= 1;
+ if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue;
+ a.cursor = k + 1;
+ return .{ .row = @intCast(k) };
+ }
+ if (a.cursor >= a.end) return null;
+ const shell = a.shell[a.cursor - a.first];
+ var p: usize = 0;
+ while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1;
+ var s: usize = 0;
+ const room = @min(line.len, shell.len) - p;
+ while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1;
+ if (p + s == 0) return null;
+ if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
+ const row = a.cursor;
+ if (s > 0 or p >= shell.len) a.cursor += 1;
+ return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
+ }
+};
+
+const Anchor = struct {
+ /// the row, absolute while it comes from `EditAnchors`, viewport once
+ /// `recolorAnsi` has subtracted the walk's offset
+ row: i32,
+ prefix: usize = std.math.maxInt(usize),
+ suffix: usize = 0,
+ /// the row's own dumped length — what the suffix is measured from on the
+ /// GRID side, where the edit may have changed the byte count
+ shell_len: usize = 0,
+
+ fn whole(an: Anchor) bool {
+ return an.prefix == std.math.maxInt(usize);
+ }
+};
+
+pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
+ // No emulator, no ANSI cells: the whole pass — and the 256-colour theme
+ // projection behind it — is compiled out.
+ if (comptime !enabled) return;
+ const s = &p.surface;
+ if (pane.terminal == null) return;
+ const body_y = p.bodyTop(r);
+ var filtered_storage: FilteredColors = undefined;
+ const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
+ const tz_filter = tracy.zone(@src(), "filterInit");
+ defer tz_filter.end();
+ filtered_storage = FilteredColors.init(p, pane);
+ break :blk &filtered_storage;
+ } else null;
+ // DECSCNM, read once: the filtered palette folds it in itself, the raw
+ // path needs it per cell.
+ const scnm = pane.terminal.?.vt.modes.get(.reverse_colors);
+ const pages = &pane.terminal.?.vt.screens.active.pages;
+ const vp_rows: i32 = @intCast(scrollbar(pane).len);
+ // Which buffer lines the user has not actually changed, so a row swallowed
+ // by a growing buffer keeps the colour it still stands over.
+ var anchors: ?EditAnchors = null;
+ var walk: BodyWalk = .init(pane);
+ var lines = std.mem.splitScalar(u8, body, '\n');
+ var vr: u16 = 0;
+ while (walk.next()) |row| : (vr += 1) {
+ if (vr >= body_h) break;
+ // Before any early exit below, or the lines fall out of step with rows.
+ const text = lines.next() orelse "";
+ const anchor: Anchor = switch (row) {
+ .edit => |e| blk: {
+ if (anchors == null) anchors = .init(p, pane, pane.ovl.?);
+ var an = anchors.?.shellRow(e.idx) orelse continue;
+ an.row -= walk.goff;
+ break :blk an;
+ },
+ .grid => |v| .{ .row = v },
+ };
+ const vi = anchor.row;
+ if (vi < 0 or vi >= vp_rows) continue;
+ const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
+ const cut: u16 = if (pane.mode == .tty) 0 else cut: {
+ if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0;
+ break :cut switch (promptCut(row_pin)) {
+ .keep => 0,
+ // Blanked end to end: the row shows nothing of the grid, so
+ // projecting the prompt's own colours onto it would be a lie.
+ .blank => continue,
+ .cut => |n| std.math.cast(u16, n) orelse continue,
+ };
+ };
+ if (cut > 0 and text.len == 0) continue;
+ var at: usize = 0;
+ var sc: u16 = 0;
+ var gc: u16 = cut;
+ var sb: usize = 0;
+ const mine_from = @min(anchor.prefix, text.len);
+ const mine_to = text.len - @min(anchor.suffix, text.len);
+ var crossed = false;
+ while (at < text.len and sc < tw) {
+ const stop = modal.nextGrapheme(text, at);
+ if (stop <= at) break;
+ const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
+ if (at >= mine_from and at < mine_to) {
+ sc += span;
+ at = stop;
+ continue;
+ }
+ if (at >= mine_to and !crossed) {
+ crossed = true;
+ const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len);
+ while (sb < upto) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ sb += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ }
+ const want = stop - at;
+ // Consume every cell that contributed to this grapheme. A cluster
+ // ghostty split across several cells is still ONE printed glyph.
+ var covered: usize = 0;
+ var style: ?pardes.CellStyle = null;
+ while (covered < want) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head)
+ style = cellStyle(p, ci, filtered, scnm);
+ covered += dumpedBytes(row_pin, ci.cell);
+ gc = std.math.add(u16, gc, 1) catch break;
+ // A spacer contributes no bytes; without this the loop would
+ // spin on a row that ends in one.
+ if (covered == 0 and gc >= pane.cols) break;
+ }
+ while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| {
+ if (t.cell.wide != .spacer_tail) break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ }
+ if (style) |st| for (0..span) |k| {
+ const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = st;
+ };
+ sb += covered;
+ sc += span;
+ at = stop;
+ }
+ var tail: ?pardes.CellStyle = null;
+ if (anchor.whole() or anchor.suffix > 0) {
+ while (sc < tw) {
+ const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
+ gc = std.math.add(u16, gc, 1) catch break;
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + sc, body_y + vr);
+ sc += 1;
+ const style = cellStyle(p, ci, filtered, scnm);
+ tail = style;
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ }
+ if (tail) |style| while (sc < tw) : (sc += 1) {
+ const cell = s.at(tx + sc, body_y + vr);
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = style;
+ };
+ }
+ }
+}
+
+fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle {
+ const style = ci.style();
+ var cs: pardes.CellStyle = .{
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ if (filtered == null) switch (ci.cell.content_tag) {
+ .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ };
+ return cs;
+}
+
+const FilteredColors = struct {
+ source: GColor.Palette,
+ target: *const GColor.Palette,
+ theme_bg: GColor.RGB,
+ theme_fg: GColor.RGB,
+ dynamic_bg: ?GColor.RGB,
+ dynamic_fg: ?GColor.RGB,
+ default_bg: GColor.RGB,
+ /// What a foreground too near `default_bg` becomes instead.
+ fallback_fg: GColor.RGB,
+ default_bg_luminance: f64,
+ fg_for_palette: [256]pardes.Color = undefined,
+ bg_for_palette: [256]pardes.Color = undefined,
+ /// The two answers for a cell that names no colour of its own.
+ fg_default: pardes.Color = undefined,
+ bg_default: pardes.Color = undefined,
+ cache_rgb: [256]GColor.RGB = undefined,
+ cache_key: [256]u8 = undefined,
+ cache_valid: [256]bool = @splat(false),
+
+ fn init(p: *Pardes, pane: *const Pane) FilteredColors {
+ var source = GColor.default;
+ for (&source, 0..) |*rgb, i|
+ rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
+ const theme = p.theme();
+ var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background });
+ var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground });
+ if (pane.terminal.?.vt.modes.get(.reverse_colors)) {
+ std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
+ std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
+ }
+ var self: FilteredColors = .{
+ .source = source,
+ .target = p.tty_filter_palette.get(theme),
+ .theme_bg = theme_bg,
+ .theme_fg = theme_fg,
+ .dynamic_bg = dynamic_bg,
+ .dynamic_fg = dynamic_fg,
+ .default_bg = theme_bg,
+ .fallback_fg = theme_fg,
+ .default_bg_luminance = luminanceOf(theme_bg),
+ };
+ if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb);
+ self.default_bg_luminance = luminanceOf(self.default_bg);
+ if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg))
+ self.fallback_fg = self.theme_bg;
+
+ self.fg_default = asPardesColor(self.legible(
+ if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg,
+ ));
+ self.bg_default = asPardesColor(self.default_bg);
+ for (&self.source, 0..) |current, i| {
+ const idx: u8 = @intCast(i);
+ const mapped = self.paletteRgb(idx, current);
+ self.fg_for_palette[idx] = asPardesColor(self.legible(mapped));
+ self.bg_for_palette[idx] = asPardesColor(mapped);
+ }
+ return self;
+ }
+
+ fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
+ return switch (style.fg_color) {
+ .none => self.fg_default,
+ .palette => |idx| self.fg_for_palette[idx],
+ .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{
+ .default = self.dynamic_fg orelse self.theme_fg,
+ .palette = &self.source,
+ .bold = null,
+ })))),
+ };
+ }
+
+ fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
+ switch (cell.content_tag) {
+ .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data],
+ .bg_color_rgb => {},
+ else => switch (style.bg_color) {
+ .none => return self.bg_default,
+ .palette => |idx| return self.bg_for_palette[idx],
+ .rgb => {},
+ },
+ }
+ // Truecolour, from either the cell or its style.
+ return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?));
+ }
+
+ fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >=
+ config.tty_filter_min_contrast) return rgb;
+ return self.fallback_fg;
+ }
+
+ fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB {
+ if (current.eql(GColor.default[idx])) return self.target[idx];
+ return self.keyedRgb(current);
+ }
+
+ fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB {
+ return self.target[self.nearestKey(rgb)];
+ }
+
+ fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
+ const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
+ const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
+ if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
+ return self.cache_key[slot];
+
+ var best: u8 = 0;
+ var best_distance: u32 = std.math.maxInt(u32);
+ for (GColor.default, 0..) |candidate, i| {
+ const distance = colorDistance(rgb, candidate);
+ // Strict comparison makes duplicate-colour ties stable at the
+ // lowest canonical xterm key.
+ if (distance < best_distance) {
+ best_distance = distance;
+ best = @intCast(i);
+ }
+ }
+ self.cache_rgb[slot] = rgb;
+ self.cache_key[slot] = best;
+ self.cache_valid[slot] = true;
+ return best;
+ }
+};
+
+const channel_luminance: [256]f64 = blk: {
+ @setEvalBranchQuota(20000);
+ var table: [256]f64 = undefined;
+ for (&table, 0..) |*slot, c| {
+ const normalized: f64 = @as(f64, @floatFromInt(c)) / 255;
+ slot.* = if (normalized <= 0.03928)
+ normalized / 12.92
+ else
+ std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
+ }
+ break :blk table;
+};
+
+fn luminanceOf(rgb: GColor.RGB) f64 {
+ return 0.2126 * channel_luminance[rgb.r] +
+ 0.7152 * channel_luminance[rgb.g] +
+ 0.0722 * channel_luminance[rgb.b];
+}
+
+/// ghostty's `RGB.contrast` with both luminances already in hand.
+fn contrastOf(a_luminance: f64, b_luminance: f64) f64 {
+ const lighter = @max(a_luminance, b_luminance);
+ const darker = @min(a_luminance, b_luminance);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
+ const dr = @as(i32, a.r) - @as(i32, b.r);
+ const dg = @as(i32, a.g) - @as(i32, b.g);
+ const db = @as(i32, a.b) - @as(i32, b.b);
+ return @intCast(dr * dr + dg * dg + db * db);
+}
+
+test "the luminance table answers exactly what ghostty computes" {
+ for (0..256) |i| {
+ const c: u8 = @intCast(i);
+ const grey: GColor.RGB = .{ .r = c, .g = c, .b = c };
+ try std.testing.expectEqual(grey.luminance(), luminanceOf(grey));
+ }
+ // Channel weights are asymmetric, so a grey ramp alone would not catch a
+ // transposed coefficient. The palette is what the tables enumerate.
+ for (GColor.default) |candidate| {
+ try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate));
+ for (GColor.default) |page| {
+ try std.testing.expectEqual(
+ candidate.contrast(page),
+ contrastOf(luminanceOf(candidate), luminanceOf(page)),
+ );
+ }
+ }
+}
+
+test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
+ const testing = std.testing;
+ // Raw index assertions require a theme that inherits the ANSI palette.
+ const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+ pane.tty_filter = false;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
+ "\x1b[38;5;196mB" ++
+ "\x1b[38;2;255;0;0mC" ++
+ "\x1b[0;48;5;25mD" ++
+ "\x1b[0;48;2;0;95;175mE" ++
+ "\x1b[0;1;2;3;4;5;7;8;9mF" ++
+ "\x1b[0;48;5;25m\x1b[K" ++
+ "\r\n\x1b[0;38;5;2m界" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const raw = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
+
+ pane.tty_filter = true;
+ _ = frame.reset(.retain_capacity);
+ const filtered = try p.render(frame.allocator());
+ var expected_cache: FilterPalette = .{};
+ const expected = expected_cache.get(p.theme());
+ try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
+ try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
+
+ const attrs = filtered.at(tx + 5, body_y).style;
+ try testing.expect(attrs.bold);
+ try testing.expect(attrs.dim);
+ try testing.expect(attrs.italic);
+ try testing.expect(attrs.blink);
+ try testing.expect(attrs.reverse);
+ try testing.expect(attrs.invisible);
+ try testing.expect(attrs.strikethrough);
+ try testing.expectEqual(.single, attrs.ul);
+
+ const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
+ try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
+ try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
+ // A filtered terminal never delegates either colour to a backend palette,
+ // including cells which were empty/default before the pass.
+ for (0..r.w - config.GUTTER) |col| {
+ const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
+ try testing.expect(!cell.default);
+ switch (cell.style.fg) {
+ .rgb => {},
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ switch (cell.style.bg) {
+ .rgb => {},
+ else => return error.FilteredBackgroundWasNotRgb,
+ }
+ }
+
+ // Colors remains the global master gate. The pane remembers Filter while
+ // ANSI projection is dormant, and resumes it without replaying VT bytes.
+ p.settings.colors = false;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ try testing.expect(pane.tty_filter);
+ try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
+ p.settings.colors = true;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
+ const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?;
+ try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ pane.tty_filter = false;
+ pane.tty_filter = true;
+ try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?));
+ _ = frame.reset(.retain_capacity);
+ const osc_palette = try p.render(frame.allocator());
+ try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
+
+ // Dynamic default foreground/background colours key every default cell,
+ // including the otherwise blank end of the row.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
+ "\x1b]11;#5f5f5f\x1b\\" } });
+ const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?;
+ const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?;
+ try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
+ _ = frame.reset(.retain_capacity);
+ const dynamic = try p.render(frame.allocator());
+ const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
+
+ const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
+ try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse);
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
+ const reversed_explicit = reversed.at(tx, body_y).style;
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg);
+ try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg);
+ try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg));
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
+ _ = frame.reset(.retain_capacity);
+ const unreversed = try p.render(frame.allocator());
+ const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
+
+ // The cache is keyed by values, not a theme name. Replacing a custom
+ // theme in place immediately recolours already-rendered indexed cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
+ var custom = p.theme().*;
+ custom.name = try p.gpa.dupe(u8, "same-name");
+ custom.palette = null;
+ custom.kw = .{ 1, 2, 3 };
+ p.custom_theme = custom;
+ _ = frame.reset(.retain_capacity);
+ const custom_first = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
+ if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
+ _ = frame.reset(.retain_capacity);
+ const custom_second = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
+}
+
+test "terminal Filter keeps extended keys dark-to-light on a light theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true });
+ defer p.deinit();
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ var cache: FilterPalette = .{};
+ const palette = cache.get(p.theme());
+ try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
+ try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
+}
+
+test "terminal Filter preserves exact palette-null light theme default roles" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ var light = p.theme().*;
+ light.name = try p.gpa.dupe(u8, "filter-light-defaults");
+ light.bg = .{ 0xf8, 0xf8, 0xf8 };
+ light.fg = .{ 0x38, 0x38, 0x38 };
+ light.palette = null;
+ p.custom_theme = light;
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }));
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }));
+ pane.tty_filter = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const ordinary = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
+}
+
+test "terminal Filter maps the default roles before it maps anything else" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+
+ // Native dark/light palettes plus legacy helix/dark/acme, including
+ // the null-page fallback that only the legacy dark theme exercises.
+ for (0..9) |t| {
+ p.settings.theme = @intCast(t);
+ const stage_one = FilteredColors.init(p, pane);
+ // `dark` declares no background of its own, which is exactly why the
+ // resolver reads the tag colours as the fallback rather than `.?`.
+ const theme = p.theme();
+ try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg)));
+ try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg)));
+ // With no OSC 11 in play the mapped page IS that anchor, and the
+ // fallback is the other one: a background never contrasts with itself.
+ try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg));
+ try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg));
+ }
+
+ p.settings.theme = 0;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } });
+ var moved = FilteredColors.init(p, pane);
+ try testing.expect(!moved.default_bg.eql(moved.theme_bg));
+ try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f })));
+}
+
+test "terminal Filter refuses a foreground that would collapse onto the page" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++
+ "\x1b[0;30mB" ++
+ "\x1b[0;31mR" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ for (0..9) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ const page = asPardesColor(fc.default_bg);
+ const rescued = asPardesColor(fc.fallback_fg);
+ _ = frame.reset(.retain_capacity);
+ const g = try p.render(frame.allocator());
+
+ // The colour each of the three would have been given with no floor.
+ const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 });
+ const raw_black = fc.paletteRgb(0, GColor.default[0]);
+ const raw_red = fc.paletteRgb(1, GColor.default[1]);
+
+ for ([_]struct { at: u16, raw: GColor.RGB }{
+ .{ .at = 0, .raw = raw_white },
+ .{ .at = 1, .raw = raw_black },
+ .{ .at = 2, .raw = raw_red },
+ }) |case| {
+ const cell = g.at(tx + case.at, body_y).style;
+ try testing.expectEqual(page, cell.bg);
+ if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) {
+ // Refused: the projection's answer is not painted, the anchor is.
+ try testing.expectEqual(rescued, cell.fg);
+ try testing.expect(!std.meta.eql(cell.fg, cell.bg));
+ } else {
+ // Cleared the floor, so stage two leaves it exactly alone.
+ try testing.expectEqual(asPardesColor(case.raw), cell.fg);
+ }
+ // Either way a filtered cell delegates neither colour to a backend.
+ switch (cell.fg) {
+ .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >=
+ config.tty_filter_min_contrast),
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ }
+
+ // At least one of the three has to have been a real collapse, or this
+ // theme proved nothing: white on the light theme, black on the dark.
+ try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or
+ raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ // A saturated red is never the page on any curated theme.
+ try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ }
+}
+
+test "terminal Filter holds every projected foreground off the page" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+
+ for (0..9) |t| {
+ p.settings.theme = @intCast(t);
+ var fc = FilteredColors.init(p, pane);
+ var refused: usize = 0;
+ for (fc.target, 0..) |projected, key| {
+ const ink = fc.legible(projected);
+ try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
+ if (!ink.eql(projected)) {
+ refused += 1;
+ try testing.expect(ink.eql(fc.fallback_fg));
+ // Only ever refused for being too near the page.
+ try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast);
+ }
+ // The key a background asks for is handed back untouched, including
+ // the one whose value is the page itself.
+ try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])]));
+ }
+ // Every curated theme owns at least one collapsing key — that is why
+ // the floor exists — and the floor must not be flattening the palette.
+ try testing.expect(refused > 0);
+ try testing.expect(refused < fc.target.len / 8);
+ }
+}
+
+test "tty ansi colors follow the prompt hug into normal mode" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] };
+ const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] };
+
+ // tty mode projects the emulator's ansi colours cell for cell.
+ pane.mode = .tty;
+ p.shell_rows.stale = true;
+ const tty = try p.render(frame.allocator());
+ try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg);
+
+ pane.mode = .normal;
+ p.shell_rows.stale = true;
+ _ = frame.reset(.retain_capacity);
+ const norm = try p.render(frame.allocator());
+ try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme());
+ try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme());
+ try testing.expectEqual(red, norm.at(tx, body_y).style.fg);
+ try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg);
+}
+
+test "a prompt row hidden end to end paints nothing at all" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ pane.tty_filter = false;
+ pane.mode = .normal;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ p.shell_rows.stale = true;
+ const s = try p.render(frame.allocator());
+
+ try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme());
+ try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg));
+}
+
+pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+}
+
+pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color {
+ return switch (color) {
+ .none => blk: {
+ const t = if (is_bg) p.theme().bg else p.theme().fg;
+ break :blk if (t) |c| .{ .rgb = c } else .default;
+ },
+ .palette => |idx| palColor(p, idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+}
+
+/// executing at a prompt with typed text below it: pad the output area
+/// with newlines so the command's output doesn't overwrite the buffer
+pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
+ // Nothing to pad away from: with no emulator there is no prompt and no
+ // child whose output could land on top of the edit buffer.
+ if (comptime !enabled) return;
+ const pane = p.panes[id] orelse return;
+ const o = pane.ovl orelse return;
+ if (!pane.isTerminal()) return;
+ const state = pane.terminal orelse return;
+ if (!state.vt.cursorIsAtPrompt()) return;
+ // the buffer's LAST surface row: its lines may outnumber the shell
+ // rows it covers, and it is the bottom one output must clear
+ const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1;
+ const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset);
+ const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y)));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) p.emitWrite(id, "\r");
+}
+
+/// the snapshot takes ownership of a COPY of the edit buffer's text
+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: Snapshot) void {
+ if (pane.ovl) |o| p.gpa.free(o.text);
+ pane.ovl = s.ovl;
+ pane.cur_row = s.cur_row;
+ pane.cur_col = s.cur_col;
+ pane.cur_pinned = true;
+ pane.vsel = s.vsel;
+ pane.msel.active = false;
+ 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]);
+}
+
+pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
+ const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal));
+ const room = state.reply.len - state.reply_len;
+ const n = @min(room, data.len);
+ @memcpy(state.reply[state.reply_len..][0..n], data[0..n]);
+ state.reply_len += @intCast(n);
+}
+
+const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
+ @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
+).optional.child).pointer.child).@"fn".return_type.?;
+pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+}
diff --git a/src/fs.zig b/src/fs.zig
index 8ed6fc77..fed15344 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -40,6 +40,7 @@ const source_files = [_]Source{
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
.{ .path = "src/image.zig", .contents = @embedFile("image.zig") },
+ .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") },
.{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") },
.{ .path = "src/fs.zig", .contents = @embedFile("fs.zig") },
.{ .path = "src/look.zig", .contents = @embedFile("look.zig") },
diff --git a/src/panes.zig b/src/panes.zig
index 051470b1..0f203e6f 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -3918,2532 +3918,7 @@ pub const Pdf = struct {
} else struct {};
};
-pub const Terminal = struct {
- pub const enabled = pardes.terminal_panes;
- const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {};
-
- pub const history_max = if (enabled) 64 else 8;
-
- pub const VtSlot = if (enabled) ghostty_vt.Terminal else void;
- pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void;
-
- /// The `aid` pardes's injected bash and fish marks carry (host_io.zig).
- pub const prompt_aid = "pardes";
-
- /// ghostty's handler, plus where the shell is in its command cycle as
- /// its OSC 133 marks tell it: ghostty parses D's exit status and drops
- /// it, and /pane/<n>/pty/run waits on exactly that. Only marks carrying
- /// pardes's own `aid` move it, so fish's native marks, a nested shell's
- /// and a stray `133;D` in some file being printed all pass it by.
- pub const MarkedHandler = if (enabled) struct {
- inner: ghostty_vt.TerminalStream.Handler,
- phase: enum { none, prompt, input, running } = .none,
- /// Counts commands started (C at the input phase).
- started: u32 = 0,
- /// Counts prompts drawn (B), so a line the shell refused without
- /// running (a fish syntax error: no C, no D, a new prompt) shows.
- prompts: u32 = 0,
- /// D marks outside a running command: the shell handled a line
- /// without starting it (bash on a syntax error prints no C).
- declined: u32 = 0,
- /// The newest prompt continues an unfinished line (bash's PS2, marked
- /// `k=c`): the shell still holds the start of the command.
- continuation: bool = false,
- /// Where the newest command's output began (its C) and ended (its
- /// D) on the primary screen, pinned so scrolling keeps them.
- out_start: ?*ghostty_vt.Pin = null,
- out_end: ?*ghostty_vt.Pin = null,
- /// The running command's output was erased under it -- a clear, ED 3,
- /// a reset -- so the pins no longer span what it printed: ED 3 moves
- /// a pin to the next page without flagging it `garbage`
- /// (PageList.erasePage).
- lost: bool = false,
-
- /// The newest command to end: which one, and its status if given.
- finished: ?struct { seq: u32, status: ?i32 } = null,
-
- const Action = ghostty_vt.TerminalStream.Action;
-
- /// Move one of the output pins to the cursor. Only the primary screen
- /// is pinned: a command's own full-screen display is not its output.
- fn pinCursor(self: *@This(), slot: *?*ghostty_vt.Pin, now: bool) void {
- const screen = self.inner.terminal.screens.get(.primary) orelse return;
- if (slot.*) |old| screen.pages.untrackPin(old);
- slot.* = null;
- if (!now or self.inner.terminal.screens.active_key != .primary) return;
- slot.* = screen.pages.trackPin(screen.cursor.page_pin.*) catch null;
- }
-
- pub fn vt(self: *@This(), comptime action: Action.Tag, value: Action.Value(action)) void {
- if (comptime action == .semantic_prompt) mark: {
- const aid = value.readOption(.aid) orelse break :mark;
- if (!std.mem.eql(u8, aid, prompt_aid)) break :mark;
- switch (value.action) {
- .fresh_line_new_prompt => {
- self.phase = .prompt;
- self.continuation = value.readOption(.prompt_kind) == .continuation;
- },
- .end_prompt_start_input => {
- self.phase = .input;
- self.prompts +%= 1;
- },
- // bash's DEBUG trap also fires on its own PROMPT_COMMAND,
- // outside the input phase; only a C that ends input counts
- .end_input_start_output => if (self.phase == .input) {
- self.phase = .running;
- self.started +%= 1;
- self.lost = false;
- self.pinCursor(&self.out_start, true);
- self.pinCursor(&self.out_end, false);
- },
- .end_command => if (self.phase == .running) {
- self.phase = .none;
- self.finished = .{ .seq = self.started, .status = value.readOption(.exit_code) };
- self.pinCursor(&self.out_end, true);
- } else {
- self.declined +%= 1;
- },
- else => {},
- }
- }
- if (comptime action == .erase_display_complete or action == .erase_display_scrollback or action == .full_reset) {
- if (self.phase == .running) self.lost = true;
- }
- self.inner.vt(action, value);
- }
-
- pub fn deinit(self: *@This()) void {
- self.inner.deinit();
- }
- } else void;
-
- pub const Replay = struct {
- bytes: [1024 * 1024]u8 = undefined,
- head: usize = 0,
- len: usize = 0,
- };
-
- pub const State = struct {
- vt: VtSlot,
- stream: StreamSlot,
- replay: Replay,
- reply: [256]u8 = undefined,
- reply_len: u16 = 0,
- };
-
- const GColor = ghostty_vt.color;
-
- const FilterPaletteKey = struct {
- bg: GColor.RGB,
- fg: GColor.RGB,
- base: [16]GColor.RGB,
- };
-
- pub const FilterPalette = if (enabled) LivePalette else struct {};
-
- const LivePalette = struct {
- key: ?FilterPaletteKey = null,
- colors: GColor.Palette = GColor.default,
-
- fn get(self: *LivePalette, theme: *const pardes.Theme) *const GColor.Palette {
- const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
- const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
- var base: [16]GColor.RGB = undefined;
- if (theme.palette) |palette| {
- for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
- } else {
- const synthesized = [16][3]u8{
- theme.bg orelse theme.tag_bg,
- theme.kw,
- theme.str,
- theme.num,
- theme.box,
- theme.sel_bg,
- theme.comment,
- theme.fg orelse theme.tag_fg,
- theme.lineno,
- theme.kw,
- theme.str,
- theme.num,
- theme.scroll_thumb,
- theme.sel_fg,
- theme.tag_fg,
- theme.fg orelse theme.tag_fg,
- };
- for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
- }
-
- const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
- if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
-
- var seed = GColor.default;
- for (base, 0..) |rgb, i| seed[i] = rgb;
- self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
- self.key = key;
- return &self.colors;
- }
- };
-
- fn asGhostRgb(rgb: [3]u8) GColor.RGB {
- return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
- }
-
- fn asPardesColor(rgb: GColor.RGB) pardes.Color {
- return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
- }
-
- /// 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: Pane.CharSel,
- };
-
- pub const PendingCommand = struct {
- bytes: []u8 = &.{},
- wait: enum { none, spawn, input } = .none,
- };
-
- pub fn armShellSpawn(pane: *Pane) void {
- if (comptime !enabled) return;
- std.debug.assert(pane.pending_command.bytes.len == 0);
- pane.pending_command.wait = .spawn;
- }
-
- 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;
- pane.greet = false;
- return true;
- }
-
- 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 {
- pane.greet = false;
- releasePendingCommand(p, id, pane, true);
- }
- }
-
- 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 = .{};
- }
-
- pub fn terminalIo() std.Io {
- return if (comptime !pardes.hosted)
- std.Io.failing
- else
- std.Io.Threaded.global_single_threaded.io();
- }
-
- /// The three numbers ghostty's scrollbar reports; all zero without an emulator.
- pub const Scrollbar = struct { total: usize = 0, offset: usize = 0, len: usize = 0 };
-
- pub fn scrollbar(pane: *const Pane) Scrollbar {
- if (comptime !enabled) return .{};
- const state = pane.terminal orelse return .{};
- const sb = state.vt.screens.active.pages.scrollbar();
- return .{ .total = sb.total, .offset = sb.offset, .len = sb.len };
- }
-
- /// The emulator's viewport offset, in SHELL rows: the top of what it shows.
- pub fn gridOffset(pane: *const Pane) i32 {
- return @intCast(scrollbar(pane).offset);
- }
-
- /// Where the emulator itself puts the cursor, in active-area cells — the origin
- /// without one, which is where an empty pane's cursor belongs anyway.
- pub const GridCursor = struct { x: u16 = 0, y: u16 = 0 };
-
- pub fn gridCursor(pane: *const Pane) GridCursor {
- if (comptime !enabled) return .{};
- const state = pane.terminal orelse return .{};
- const cur = state.vt.screens.active.cursor;
- return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
- }
-
- pub fn visibleCursor(pane: *const Pane) ?GridCursor {
- if (comptime !enabled) return null;
- const state = pane.terminal orelse return null;
- if (!state.vt.modes.get(.cursor_visible)) return null;
- const cur = state.vt.screens.active.cursor;
- const sb = scrollbar(pane);
- const row = sb.total - sb.len + cur.y;
- if (row < sb.offset or row - sb.offset >= sb.len) return null;
- return .{ .x = @intCast(cur.x), .y = @intCast(row - sb.offset) };
- }
-
- /// Move the emulator's viewport by `delta` shell rows (negative scrolls back).
- pub fn scrollGrid(pane: *Pane, delta: i32) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- state.vt.screens.active.scroll(.{ .delta_row = delta });
- }
-
- /// Is the viewport already showing the last row of output?
- pub fn atBottom(pane: *const Pane) bool {
- if (comptime !enabled) return true;
- const sb = scrollbar(pane);
- return sb.offset + sb.len >= sb.total;
- }
-
- /// Snap the viewport back onto live output.
- pub fn followOutput(pane: *Pane) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- state.vt.screens.active.scroll(.active);
- }
-
- /// Reflow the grid. A failed reflow keeps the grid it had rather than dropping
- /// a scrollback; the next resize retries with the same numbers.
- pub fn resizeGrid(pane: *Pane, gpa: std.mem.Allocator, cols: u16, rows: u16) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- if (cols != pane.cols) for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- };
- state.vt.resize(gpa, .{ .cols = cols, .rows = rows }) catch {};
- }
-
- /// DECSET 2004: the program wants its pastes bracketed.
- pub fn bracketedPaste(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const state = pane.terminal orelse return false;
- return state.vt.modes.get(.bracketed_paste);
- }
-
- /// The program tracks the mouse itself, so a click in its body is its event.
- pub fn reportsMouse(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const state = pane.terminal orelse return false;
- const m = &state.vt.modes;
- return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any);
- }
-
- /// ...and wants them in SGR (1006) rather than the legacy X10 bytes.
- pub fn mouseFormatSgr(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const state = pane.terminal orelse return false;
- return state.vt.modes.get(.mouse_format_sgr);
- }
-
- /// The whole scrollback as plain text, `gpa`-owned: what `Save` writes out.
- pub fn screenTextAlloc(pane: *Pane, gpa: std.mem.Allocator) ![]const u8 {
- if (comptime !enabled) return &.{};
- const state = pane.terminal orelse return &.{};
- return state.vt.screens.active.dumpStringAlloc(gpa, .{ .screen = .{} });
- }
-
- /// Release the emulator's heap. The Pane allocation itself is the core's.
- pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void {
- if (comptime !enabled) return;
- const state = pane.terminal orelse return;
- state.stream.deinit();
- state.vt.deinit(gpa);
- gpa.destroy(state);
- pane.terminal = null;
- }
-
- /// 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 createDoc(gpa, cols, rows);
- errdefer gpa.destroy(pane);
- if (comptime !enabled) return pane;
- const state = try gpa.create(State);
- errdefer gpa.destroy(state);
- state.vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{
- .cols = cols,
- .rows = rows,
- .max_scrollback = 16 * 1024 * 1024,
- });
- state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() });
- state.replay.head = 0;
- state.replay.len = 0;
- state.reply_len = 0;
- state.stream.handler.inner.effects.write_pty = ptyReport;
- state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs;
- pane.terminal = state;
- pane.tty_filter = true;
- return pane;
- }
-
- pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
- const pane = try gpa.create(Pane);
- pane.* = .{
- .gpa = gpa,
- .cols = cols,
- .rows = rows,
- };
- return pane;
- }
-
- /// Where a restored terminal's history ends and its new shell begins.
- pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n";
-
- /// Rebuild a dump's terminal emulator from its recorded output. `live`: a
- /// shell will be spawned into it, so the history is marked off from what
- /// that shell prints and the pane waits for it like any new terminal.
- /// 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, live: bool) !*Pane {
- const terminal = src.terminal.?;
- if (comptime !enabled) {
- const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
- errdefer p.gpa.destroy(pane);
- if (terminal.stream.len > 0)
- pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) };
- return pane;
- }
- // An older dump's raw output tail cannot come back live: cut off mid
- // redraw, or written at sizes the pane no longer has, it replays as
- // garbage. Its rendered text can.
- const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0;
- const bytes = if (legacy_text)
- try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n")
- else 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 (live) {
- if (bytes.len > 0) {
- ingest(pane, bytes);
- ingest(pane, restored_banner);
- }
- followOutput(pane);
- // A view left scrolled back stays on what it was showing: the
- // banner's line breaks scrolled a full screen by that many more.
- if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n"))));
- armShellSpawn(pane);
- } else if (bytes.len > 0) {
- ingest(pane, bytes);
- followOutput(pane);
- if (src.scroll > 0)
- scrollGrid(pane, -@as(i32, @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 {
- const state = pane.terminal.?;
- const replay = &state.replay;
- if (bytes.len >= replay.bytes.len) {
- const kept = bytes[bytes.len - replay.bytes.len ..];
- @memcpy(&replay.bytes, kept);
- replay.head = 0;
- replay.len = replay.bytes.len;
- } else {
- const overflow = bytes.len -| (replay.bytes.len - replay.len);
- replay.head = (replay.head + overflow) % replay.bytes.len;
- replay.len -= overflow;
- const tail = (replay.head + replay.len) % replay.bytes.len;
- const first = @min(bytes.len, replay.bytes.len - tail);
- @memcpy(replay.bytes[tail..][0..first], bytes[0..first]);
- @memcpy(replay.bytes[0 .. bytes.len - first], bytes[first..]);
- replay.len += bytes.len;
- }
- state.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 {
- const state = pane.terminal orelse return &.{};
- const replay = &state.replay;
- if (replay.len == 0) return &.{};
- if (replay.head + replay.len <= replay.bytes.len)
- return replay.bytes[replay.head..][0..replay.len];
- const out = try allocator.alloc(u8, replay.len);
- const first = replay.bytes.len - replay.head;
- @memcpy(out[0..first], replay.bytes[replay.head..]);
- @memcpy(out[first..], replay.bytes[0 .. replay.len - first]);
- return out;
- }
-
- test "replay ownership is terminal-only and construction rolls back on allocation failure" {
- const Case = struct {
- fn run(gpa: std.mem.Allocator, terminal: bool) !void {
- const pane = if (terminal) try create(gpa, 40, 12) else try createDoc(gpa, 40, 12);
- defer gpa.destroy(pane);
- defer deinitEmulator(pane, gpa);
- try std.testing.expectEqual(terminal and enabled, pane.terminal != null);
- }
- };
- try std.testing.expect(@sizeOf(Pane) < 128 * 1024);
- try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false});
- try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true});
- }
-
- test "document construction allocates only the pane and has inert terminal defaults" {
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
- const allocator = failing.allocator();
- const pane = try createDoc(allocator, 40, 12);
- defer allocator.destroy(pane);
- defer deinitEmulator(pane, allocator);
- try std.testing.expect(pane.terminal == null);
- try std.testing.expectEqual(@as(usize, 1), failing.alloc_index);
- try std.testing.expectEqual(@as(usize, 0), failing.resize_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(@as(usize, @sizeOf(Pane)), failing.allocated_bytes);
- try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
- try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
- try std.testing.expect(!bracketedPaste(pane));
- try std.testing.expect(!reportsMouse(pane));
- try std.testing.expect(!mouseFormatSgr(pane));
- try std.testing.expect(!promptInputReady(pane));
- scrollGrid(pane, 100);
- followOutput(pane);
- resizeGrid(pane, allocator, 80, 24);
- try std.testing.expectEqual(GridCursor{}, gridCursor(pane));
- try std.testing.expectEqual(Scrollbar{}, scrollbar(pane));
- try std.testing.expectEqualStrings("", try screenTextAlloc(pane, allocator));
- try std.testing.expectEqualStrings("", try replayBytes(pane, allocator));
- try std.testing.expect(!failing.has_induced_failure);
- }
-
- test "terminal state keeps parser fragments and sends emulator replies through its owner" {
- if (comptime !enabled) return error.SkipZigTest;
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- const state = pane.terminal.?;
- try std.testing.expect(state.stream.handler.inner.terminal == &state.vt);
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[" } });
- while (p.nextEffect()) |effect| {
- if (effect == .write) return error.PrematureTerminalReply;
- }
- p.update(.{ .output = .{ .pane = 0, .bytes = "6n" } });
- var replies: usize = 0;
- while (p.nextEffect()) |effect| {
- if (effect != .write) continue;
- try std.testing.expectEqual(@as(u8, 0), effect.write.pane);
- try std.testing.expectEqualStrings("\x1b[1;1R", effect.write.bytes.slice());
- replies += 1;
- }
- try std.testing.expectEqual(@as(usize, 1), replies);
- try std.testing.expectEqual(@as(u16, 0), state.reply_len);
- try std.testing.expectEqualStrings("\x1b[6n", try replayBytes(pane, std.testing.allocator));
- }
-
- test "replay keeps a bounded chronological suffix across large writes and wrapping" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const pane = try create(gpa, 40, 12);
- defer gpa.destroy(pane);
- defer deinitEmulator(pane, gpa);
- try std.testing.expectEqualStrings("", try replayBytes(pane, gpa));
- ingest(pane, "hello");
- const small = try replayBytes(pane, gpa);
- try std.testing.expectEqualStrings("hello", small);
- try std.testing.expectEqual(@intFromPtr(&pane.terminal.?.replay.bytes), @intFromPtr(small.ptr));
-
- const capacity = pane.terminal.?.replay.bytes.len;
- const input = try gpa.alloc(u8, capacity + 7);
- defer gpa.free(input);
- @memset(input, 0);
- const ending = "\x1b[31mred\x1b[0m\r\n";
- @memcpy(input[input.len - ending.len ..], ending);
- ingest(pane, input);
- try std.testing.expectEqualSlices(u8, input[7..], try replayBytes(pane, gpa));
- ingest(pane, "next\r\n");
- const wrapped = try replayBytes(pane, gpa);
- defer gpa.free(wrapped);
- try std.testing.expectEqual(capacity, wrapped.len);
- try std.testing.expectEqualSlices(u8, input[7 + "next\r\n".len ..], wrapped[0 .. capacity - "next\r\n".len]);
- try std.testing.expectEqualStrings("next\r\n", wrapped[capacity - "next\r\n".len ..]);
- }
-
- test "replay restores terminal cells from the retained stream" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- feedOutput(p, pane, "\x1b[31mred\x1b[0m\r\ncafé\r\n");
- const encoded = try dump.encodeBytes(gpa, try replayBytes(pane, gpa));
- defer gpa.free(encoded);
- const restored = try restore(p, .{
- .kind = .terminal,
- .tag = "",
- .body = "",
- .cols = pane.cols,
- .rows = pane.rows,
- .terminal = .{ .stream_b64 = encoded },
- }, false);
- defer gpa.destroy(restored);
- defer deinitEmulator(restored, gpa);
- const before = try screenTextAlloc(pane, gpa);
- defer gpa.free(before);
- const after = try screenTextAlloc(restored, gpa);
- defer gpa.free(after);
- try std.testing.expectEqualStrings(before, after);
- try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
- }
-
- test "a restored terminal keeps its history above a marker and gets a shell where it was" {
- if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- feedOutput(p, pane, "old output\r\n$ ls\r\n");
- try pane.setOwnedCwd("/tmp");
- try p.dumpState();
- const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
- defer restored.deinit();
- var spawned = false;
- while (restored.nextEffect()) |effect| switch (effect) {
- .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"),
- else => {},
- };
- try std.testing.expect(spawned);
- const again = restored.panes[0].?;
- try std.testing.expectEqual(.spawn, again.pending_command.wait);
- const text = try screenTextAlloc(again, gpa);
- defer gpa.free(text);
- const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost;
- const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker;
- try std.testing.expect(old_at < mark_at);
- }
-
- test "an older dump comes back live from its rendered text, not its raw output tail" {
- if (comptime !enabled) return error.SkipZigTest;
- const gpa = std.testing.allocator;
- const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 });
- defer p.deinit();
- // A tail cut mid-redraw: relative moves from a cursor it never set.
- const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore");
- defer gpa.free(tail);
- const restored = try restore(p, .{
- .kind = .terminal,
- .tag = "",
- .body = "",
- .cols = 40,
- .rows = 12,
- .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail },
- }, true);
- defer gpa.destroy(restored);
- defer deinitEmulator(restored, gpa);
- defer deinitPendingCommand(restored);
- const text = try screenTextAlloc(restored, gpa);
- defer gpa.free(text);
- try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null);
- }
-
- /// 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 {
- // There are no pty reads at all without an emulator to parse them into.
- if (comptime !enabled) return;
- if (pane.terminal == null) return;
- const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or
- pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or
- pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0;
- // Sticky bottom, as every other terminal does it: output drags the
- // viewport down only when the viewport was already on the last row, so
- // a reader scrolled back into the scrollback stays where they scrolled.
- const at_bottom = atBottom(pane);
- if (has_positions) ingestWithPositions(pane, bytes) else 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 and at_bottom) followOutput(pane);
- }
-
- const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 };
- const PositionPin = struct {
- target: *i32,
- row: RowPin,
- overlay: ?*EditBuffer,
- edit_row: ?i32 = null,
- };
-
- fn trackRow(pages: *ghostty_vt.PageList, row: i32, failed: *bool) RowPin {
- if (row < 0) return .{ .offset = row };
- const bounded: usize = @min(@as(usize, @intCast(row)), pages.total_rows - 1);
- var left = pages.total_rows - 1 - bounded;
- var node = pages.pages.last.?;
- while (left >= node.rows()) {
- left -= node.rows();
- node = node.prev.?;
- }
- const pin = pages.trackPin(.{ .node = node, .y = @intCast(node.rows() - 1 - left) }) catch {
- failed.* = true;
- return .{};
- };
- return .{ .pin = pin, .offset = row - @as(i32, @intCast(bounded)) };
- }
-
- fn pinnedRow(pages: ?*ghostty_vt.PageList, row: RowPin) i32 {
- const pin = row.pin orelse return row.offset;
- const alive = pages orelse return 0;
- if (pin.garbage) return 0;
- var after: usize = 0;
- var node = alive.pages.last;
- while (node) |item| : (node = item.prev) {
- if (item == pin.node)
- return @as(i32, @intCast(alive.total_rows - after - item.rows() + pin.y)) +| row.offset;
- after += item.rows();
- }
- return 0;
- }
-
- fn trackPosition(pages: *ghostty_vt.PageList, target: *i32, overlay: ?*EditBuffer, failed: *bool) PositionPin {
- var grid = target.*;
- if (overlay) |edit| {
- const lines: i32 = @intCast(modal.lineCount(edit.text));
- if (grid >= edit.row and grid < edit.row +| lines)
- return .{ .target = target, .row = .{}, .overlay = edit, .edit_row = grid - edit.row };
- if (grid > edit.row) grid = grid -| lines +| edit.rows;
- }
- return .{ .target = target, .row = trackRow(pages, grid, failed), .overlay = overlay };
- }
-
- fn pointerRowCount(pane: *const Pane) usize {
- var count: usize = 0;
- for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
- count += rows.len;
- };
- return count;
- }
-
- fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
- const screens = &pane.terminal.?.vt.screens;
- const key = screens.active_key;
- const generation = screens.generation(key);
- const pages = &screens.active.pages;
- const Group = struct { overlay: ?*EditBuffer, cursor: *i32, selection: *Pane.CharSel };
- var groups: [history_max * 2 + 1]Group = undefined;
- groups[0] = .{
- .overlay = if (pane.ovl) |*overlay| overlay else null,
- .cursor = &pane.cur_row,
- .selection = &pane.vsel,
- };
- var ngroups: usize = 1;
- for ([_][]Snapshot{ pane.ed_undo[0..pane.ed_undo_len], pane.ed_redo[0..pane.ed_redo_len] }) |history| {
- for (history) |*snapshot| {
- groups[ngroups] = .{
- .overlay = if (snapshot.ovl) |*overlay| overlay else null,
- .cursor = &snapshot.cur_row,
- .selection = &snapshot.vsel,
- };
- ngroups += 1;
- }
- }
- const Span = struct { overlay: *EditBuffer, start: RowPin, end: RowPin };
- var spans: [groups.len]Span = undefined;
- var nspans: usize = 0;
- var positions: [groups.len * 2 + Pane.max_selections * 2 + 4]PositionPin = undefined;
- var npositions: usize = 0;
- var failed = false;
- for (groups[0..ngroups]) |group| {
- if (group.overlay) |overlay| {
- spans[nspans] = .{
- .overlay = overlay,
- .start = trackRow(pages, overlay.row, &failed),
- .end = trackRow(pages, overlay.row +| (overlay.rows - 1), &failed),
- };
- nspans += 1;
- }
- positions[npositions] = trackPosition(pages, group.cursor, group.overlay, &failed);
- npositions += 1;
- if (group.selection.active) {
- positions[npositions] = trackPosition(pages, &group.selection.row, group.overlay, &failed);
- npositions += 1;
- }
- }
- const overlay = groups[0].overlay;
- var extra: [Pane.max_selections * 2 + 4]*i32 = undefined;
- var nextra: usize = 0;
- if (pane.msel.active) {
- extra[nextra] = &pane.msel.r0;
- extra[nextra + 1] = &pane.msel.r1;
- nextra += 2;
- }
- for (pane.sels[0..pane.nsel]) |*selection| {
- extra[nextra] = &selection.row;
- extra[nextra + 1] = &selection.arow;
- nextra += 2;
- }
- if (pane.append_at) |*at| {
- extra[nextra] = &at.row;
- nextra += 1;
- }
- if (pane.look_at) |*at| {
- extra[nextra] = &at.row;
- nextra += 1;
- }
- for (extra[0..nextra]) |target| {
- positions[npositions] = trackPosition(pages, target, overlay, &failed);
- npositions += 1;
- }
- const pointer_positions = pane.gpa.alloc(PositionPin, pointerRowCount(pane)) catch null;
- defer if (pointer_positions) |pins| pane.gpa.free(pins);
- if (pointer_positions) |pins| {
- var index: usize = 0;
- for (pane.sel) |gesture| if (pane.pointerSelection(gesture)) |rows| {
- for (rows) |*row| {
- pins[index] = trackPosition(pages, &row.row, if (row.raw_terminal) null else overlay, &failed);
- index += 1;
- }
- };
- } else failed = true;
- ingest(pane, bytes);
- // RIS can destroy the alternate screen and every pin it owned.
- const alive = if (screens.generation(key) == generation) pages else null;
- var moved = false;
- var pointer_rows_lost = alive == null or pointer_positions == null;
- if (pointer_positions) |pins| for (pins) |position| {
- if (alive != null) if (position.row.pin) |pin| {
- if (pin.garbage) pointer_rows_lost = true;
- };
- };
- for (spans[0..nspans]) |span| {
- const start = pinnedRow(alive, span.start);
- const rows = @max(1, pinnedRow(alive, span.end) -| start +| 1);
- moved = moved or start != span.overlay.row or rows != span.overlay.rows;
- span.overlay.row = start;
- span.overlay.rows = rows;
- if (alive) |owner| {
- if (span.start.pin) |pin| owner.untrackPin(pin);
- if (span.end.pin) |pin| owner.untrackPin(pin);
- }
- }
- for ([_][]PositionPin{ positions[0..npositions], pointer_positions orelse &.{} }) |batch| for (batch) |position| {
- var row = pinnedRow(alive, position.row);
- if (position.overlay) |edit| {
- if (position.edit_row) |relative| {
- row = edit.row +| relative;
- } else if (row > edit.row) {
- row = if (row < edit.row +| edit.rows)
- edit.row
- else
- row -| edit.rows +| @as(i32, @intCast(modal.lineCount(edit.text)));
- }
- }
- moved = moved or row != position.target.*;
- position.target.* = row;
- if (alive) |owner| if (position.row.pin) |pin| owner.untrackPin(pin);
- };
- if (pointer_rows_lost) for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- };
- if (moved) pane.nsel_snap = 0; // Saved regex-preview offsets name the old flat text.
- if (failed) {
- const message = "terminal edit position reset: out of memory";
- @memcpy(pane.msg[0..message.len], message);
- pane.msg_len = message.len;
- }
- }
-
- test "raw terminal output needs no position tracking allocations" {
- if (comptime !enabled) return error.SkipZigTest;
- var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
- const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 80, .rows = 24 });
- defer p.deinit();
- const pane = p.panes[0].?;
- pane.mode = .tty;
- feedOutput(p, pane, "warm");
- const allocations = failing.alloc_index;
- failing.fail_index = allocations;
- const pins = pane.terminal.?.vt.screens.active.pages.countTrackedPins();
- feedOutput(p, pane, " output");
- try std.testing.expectEqual(allocations, failing.alloc_index);
- try std.testing.expect(!failing.has_induced_failure);
- try std.testing.expectEqual(pins, pane.terminal.?.vt.screens.active.pages.countTrackedPins());
- }
-
- pub fn promptInputReady(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- const state = pane.terminal orelse return false;
- return state.vt.screens.active_key != .alternate and
- state.vt.screens.active.cursor.semantic_content == .input;
- }
-
- pub const CommandOutput = struct {
- /// Owned by the caller.
- text: [:0]const u8,
- /// Whether `text` starts where the command did. It does not when that
- /// start is not there to read: cut off by `max_rows`, scrolled out of
- /// the history (ghostty moved the pin to the oldest row it kept and
- /// flagged it `garbage`), erased under it (`MarkedHandler.lost`), or
- /// never pinned (a C or D that came on the alternate screen).
- whole: bool,
- };
-
- /// What the newest finished command printed: the screen text between its
- /// C and D marks, read from at most `max_rows` rows above its end, so a
- /// command that printed the whole history costs no more than its tail.
- pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
- // Unless there is text to read, whether the nothing it printed is the
- // whole of it.
- const whole = if (comptime enabled) printed: {
- const state = pane.terminal orelse break :printed true;
- const marks = &state.stream.handler;
- const start = marks.out_start orelse break :printed false;
- const end = marks.out_end orelse break :printed false;
- const screen = state.vt.screens.get(.primary) orelse break :printed false;
- if (end.garbage) break :printed false;
- var whole = !marks.lost and !start.garbage;
- // D is where the next prompt begins: the output ends a cell before.
- const last = end.leftWrap(1) orelse break :printed whole;
- if (start.eql(end.*) or last.before(start.*)) break :printed whole;
- var from = start.*;
- if (last.up(max_rows)) |floor| if (from.before(floor)) {
- from = floor;
- from.x = 0;
- whole = false;
- };
- return .{
- .text = try screen.selectionString(gpa, .{ .sel = ghostty_vt.Selection.init(from, last, false) }),
- .whole = whole,
- };
- } else true;
- return .{ .text = try gpa.dupeZ(u8, ""), .whole = whole };
- }
-
- /// At a prompt with nothing typed on its line: no input left of the
- /// cursor and none under it (text typed and then Ctrl-A'd starts at the
- /// cursor). Nothing further right counts: that is where fish draws a
- /// right prompt, and fish suggests nothing on an empty line.
- pub fn promptInputEmpty(pane: *const Pane) bool {
- if (comptime !enabled) return false;
- if (!promptInputReady(pane)) return false;
- const cursor = &pane.terminal.?.vt.screens.active.cursor;
- const row = cursor.page_pin.cells(.all);
- for (row[0..@min(cursor.x + 1, row.len)]) |cell|
- if (cell.semantic_content == .input and !cell.isEmpty()) return false;
- return true;
- }
-
- /// Encode one key for the program that owns a raw terminal and queue its pty
- /// write, respecting the application's keyboard protocol and terminal modes.
- pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
- if (comptime enabled) {
- const pane = p.panes[id] orelse return;
- const state = pane.terminal orelse return;
- const input = ghostty_vt.input;
- const physical: input.Key = switch (key.cp) {
- Key.enter => .enter,
- Key.backspace => .backspace,
- Key.tab => .tab,
- Key.escape => .escape,
- Key.up => .arrow_up,
- Key.down => .arrow_down,
- Key.left => .arrow_left,
- Key.right => .arrow_right,
- Key.home => .home,
- Key.end => .end,
- Key.page_up => .page_up,
- Key.page_down => .page_down,
- Key.delete => .delete,
- else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified,
- };
- var utf8: [4]u8 = undefined;
- const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp;
- const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace)
- utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return]
- else
- "";
- var buffer: [256]u8 = undefined;
- var writer = std.Io.Writer.fixed(&buffer);
- input.encodeKey(&writer, .{
- .key = physical,
- .mods = .{ .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift },
- .consumed_mods = .{ .shift = key.text.len > 0 and key.shift },
- .utf8 = text,
- .unshifted_codepoint = if (key.cp >= 'A' and key.cp <= 'Z') key.cp - 'A' + 'a' else if (key.cp < Key.up) key.cp else 0,
- }, options: {
- var opts = input.KeyEncodeOptions.fromTerminal(&state.vt);
- // fromTerminal cannot know this one and says so, defaulting to
- // "option is not alt" — right for an app that lets macOS do the
- // unicode translation and hands the encoder the composed text.
- // Every pardes shell resolves the modifier itself and hands the
- // core the BASE key with the alt bit and no text at all (see
- // PardesView.keyDown, and app.mjs doing the same with the same
- // two bits), so by the time this runs alt already means alt.
- // Left at the default, a macOS build drops the ESC prefix on
- // the floor and forwards Alt-n to the child as a bare `n`.
- opts.macos_option_as_alt = .true;
- break :options opts;
- }) catch return;
- if (writer.end > 0) p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(writer.buffered()) } });
- return;
- }
- 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) } });
- }
-
- pub fn enterTty(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const selected = pane.rawPointerText(pane.sel[@intFromEnum(config.select_button)]) != null;
- if (comptime enabled) if (!selected and pane.terminal != null and pane.cur_pinned and pane.terminal.?.vt.cursorIsAtPrompt()) handoff: {
- const screen = pane.terminal.?.vt.screens.active;
- 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;
- followOutput(pane); // entering raw mode is a request for the live shell
- pane.msel.active = false;
- pane.vsel.active = false;
- pane.nsel = 0;
- // The raw renderer follows the live cursor. A captured mouse selection
- // keeps its modal endpoint for the next return to editor mode.
- pane.cur_pinned = selected;
- pane.select = false;
- pane.append_at = null;
- pane.sticky_col = -1;
- pane.normal.clear();
- }
-
- // Returned slices remain valid until the next cache rebuild or reset.
- pub const RowsCache = struct {
- /// whose grid this describes; null = the slot is free
- pane: ?*const Pane = null,
- /// the rows joined by '\n' — `flatSurface` hands this back verbatim
- /// instead of rebuilding the join on every keystroke
- text: []const u8 = &.{},
- /// slices INTO `text`, absolute grid rows from 0
- rows: [][]const u8 = &.{},
- /// `text` is a prefix of this: blanking a prompt row shortens the join,
- /// and the slack is not worth a second allocation to reclaim
- text_alloc: []u8 = &.{},
- stale: bool = false,
-
- pub fn reset(c: *RowsCache, gpa: std.mem.Allocator) void {
- if (c.text_alloc.len > 0) gpa.free(c.text_alloc);
- if (c.rows.len > 0) gpa.free(c.rows);
- c.* = .{};
- }
-
- /// Free a stale entry. Called at the top of `update`, and nowhere else.
- pub fn sweep(c: *RowsCache, gpa: std.mem.Allocator) void {
- if (c.stale) c.reset(gpa);
- }
-
- /// `pane`'s grid moved: the entry no longer describes it.
- pub fn markStale(c: *RowsCache, pane: *const Pane) void {
- if (c.pane == pane) c.stale = true;
- }
-
- pub fn dropPane(c: *RowsCache, pane: *const Pane) void {
- if (c.pane != pane) return;
- c.pane = null;
- c.stale = true;
- }
- };
-
- const Rows = struct {
- text_alloc: []u8,
- text: []const u8,
- rows: [][]const u8,
- };
-
- const empty_grid = [1][]const u8{""};
-
- /// 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.
- const PromptCut = union(enum) {
- /// show the row exactly as ghostty dumped it
- keep,
- /// show nothing at all
- blank,
- /// drop this many leading COLUMNS — the prompt — and keep the rest, which
- /// is what was typed at it
- cut: usize,
- };
-
- 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;
- while (cols < cells.len and cells[cols].semantic_content == .prompt) cols += 1;
- // Flagged, but with no prompt cells at the FRONT: a right-side prompt, or
- // a repaint that has moved on. Nothing here is the prompt, so hide nothing.
- if (cols == 0) return .keep;
- // ...and all prompt, nothing typed yet: the row is chrome end to end.
- if (cols >= cells.len) return .blank;
- return .{ .cut = cols };
- }
-
- pub fn promptPrefixBytes(pane: *Pane, row: i32, raw: []const u8) usize {
- if (comptime !enabled) return 0;
- if (row < 0) return 0;
- const state = pane.terminal orelse return 0;
- const pin = state.vt.screens.active.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(row) } }) orelse return 0;
- if (pin.rowAndCell().row.semantic_prompt == .none) return 0;
- return switch (promptCut(pin)) {
- .keep => 0,
- .blank => raw.len,
- .cut => |cols| @min(raw.len, File.rawAtDisplay(raw, cols)),
- };
- }
-
- fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
- const cols = switch (promptCut(pin)) {
- .keep => return raw,
- .blank => return "",
- .cut => |n| n,
- };
- const cells = pin.cells(.all);
- var at: usize = 0;
- var col: usize = 0;
- while (col < cols and at < raw.len) {
- const cell = &cells[col];
- at = @min(raw.len, at + dumpedBytes(pin, cell));
- // the tail cell of a wide glyph spells nothing of its own
- col += if (cell.wide == .wide) @as(usize, 2) else 1;
- }
- return std.mem.trimEnd(u8, raw[at..], " \t");
- }
-
- fn dumpedBytes(pin: ghostty_vt.Pin, cell: *const ghostty_vt.Cell) usize {
- switch (cell.wide) {
- .spacer_head, .spacer_tail => return 0,
- .narrow, .wide => {},
- }
- var n: usize = switch (cell.content_tag) {
- .codepoint, .codepoint_grapheme => std.unicode.utf8CodepointSequenceLength(
- cell.codepoint(),
- ) catch 1,
- // A cell carrying only a colour still spells one blank in the dump.
- else => 1,
- };
- if (cell.content_tag == .codepoint_grapheme) {
- if (pin.grapheme(cell)) |extra| for (extra) |cp| {
- n += std.unicode.utf8CodepointSequenceLength(cp) catch 1;
- };
- }
- return n;
- }
-
- pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
- if (comptime !enabled) return &empty_grid;
- if (pane.terminal == null) return &empty_grid;
- const c = &p.shell_rows;
- if (!c.stale and c.pane == pane) return c.rows;
- if (c.pane != null) {
- c.stale = true;
- return (try buildRows(p.scratch.allocator(), p, pane)).rows;
- }
- const built = try buildRows(p.gpa, p, pane);
- c.* = .{
- .pane = pane,
- .text = built.text,
- .rows = built.rows,
- .text_alloc = built.text_alloc,
- };
- 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);
- for (0..pane.sel.len) |button| {
- pane.clearPointerSelection(button);
- pane.sel[button].state = .none;
- }
- p.gpa.free(overlay.text);
- overlay.text = new;
- }
-
- /// The shell's screen and scrollback (the primary screen, even under a
- /// full-screen program) as VT that replays to what was shown: styled
- /// text, soft-wrapped lines rejoined so they reflow at another width.
- fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 {
- const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{};
- var out: std.Io.Writer.Allocating = .init(arena);
- try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer);
- return out.written();
- }
-
- /// 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 {
- if (!enabled or pane.terminal == null) {
- const text = if (pane.ovl) |overlay| overlay.text else "";
- return .{
- .kind = .terminal,
- .tag = tag,
- .body = body,
- .scroll = scroll,
- .cols = pane.cols,
- .rows = pane.rows,
- .vweight = pane.vweight,
- .collapsed = pane.collapsed,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = try arena.dupe(u8, text),
- .stream_b64 = &.{},
- .cursor = .{ .col = 0, .row = 0 },
- },
- };
- }
- const full = try pane.terminal.?.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.terminal.?.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,
- .collapsed = pane.collapsed,
- .terminal = .{
- .cwd = try arena.dupe(u8, pane.cwdSlice()),
- .stream = stream[0..len],
- .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
- .stream_is_screen = true,
- .cursor = .{
- .col = pane.terminal.?.vt.screens.active.cursor.x,
- .row = pane.terminal.?.vt.screens.active.cursor.y,
- },
- },
- };
- }
-
- fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
- const full = try pane.terminal.?.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} });
- var pit = pane.terminal.?.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
- // split yields one more item than delimiters; the extra final slot is the
- // cursor row retained below.
- const n_rows = std.mem.count(u8, full, "\n") + 2;
- const rows = try alloc.alloc([]const u8, n_rows);
- errdefer alloc.free(rows);
- // Blanking a prompt row only ever SHORTENS it and the retained cursor row
- // adds one separator, so the dump's length plus one bounds the join.
- const text = try alloc.alloc(u8, full.len + 1);
- errdefer alloc.free(text);
- var at: usize = 0;
- var n: usize = 0;
- var it = std.mem.splitScalar(u8, full, '\n');
- while (it.next()) |raw| {
- const shown = if (pit.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw
- else
- raw;
- if (n > 0) {
- text[at] = '\n';
- at += 1;
- }
- @memcpy(text[at..][0..shown.len], shown);
- rows[n] = text[at..][0..shown.len];
- at += shown.len;
- n += 1;
- }
- text[at] = '\n';
- at += 1;
- rows[n] = text[at..][0..0];
- n += 1;
- std.debug.assert(n == n_rows);
- return .{ .text_alloc = text, .text = text[0..at], .rows = rows };
- }
-
- pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
- const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: {
- const state = pane.terminal orelse break :vp &.{};
- const screen = state.vt.screens.active;
- var tl = screen.pages.getTopLeft(.viewport);
- tl.x = 0;
- const br = screen.pages.getBottomRight(.viewport) orelse return error.UnknownPoint;
- var rows_out: std.Io.Writer.Allocating = .init(arena);
- try screen.dumpString(&rows_out.writer, .{ .tl = tl, .br = br, .unwrap = false });
- const raw = try rows_out.toOwnedSlice();
- var prompts = screen.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
- const vp = try arena.alloc([]const u8, std.mem.count(u8, raw, "\n") + 1);
- var lines = std.mem.splitScalar(u8, raw, '\n');
- var n: usize = 0;
- while (lines.next()) |ln| {
- vp[n] = if (pane.mode != .tty)
- if (prompts.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, ln) else ln
- else
- ln
- else
- ln;
- n += 1;
- }
- std.debug.assert(n == vp.len);
- break :vp vp;
- };
-
- const len = fillBody(null, pane, vp);
- const out = try arena.alloc(u8, len);
- const filled = fillBody(out, pane, vp);
- std.debug.assert(filled == out.len);
- return out;
- }
-
- pub const BodyRow = union(enum) {
- grid: i32,
- edit: struct { line: []const u8, idx: usize },
- };
-
- const BodyWalk = struct {
- pane: *Pane,
- goff: i32,
- g: i32,
- /// the buffer can start above the viewport: drop the lines scrolled past
- skip: usize,
- n: usize = 0,
- lines: ?std.mem.SplitIterator(u8, .scalar) = null,
- covered: i32 = 0,
- line_idx: usize = 0,
-
- fn init(pane: *Pane) BodyWalk {
- const off = pane.scroll();
- const goff = gridOffset(pane);
- return .{
- .pane = pane,
- .goff = goff,
- .g = if (pane.mode == .tty) goff else pane.gridRow(off),
- .skip = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0,
- };
- }
-
- fn next(w: *BodyWalk) ?BodyRow {
- while (w.n < w.pane.rows) {
- if (w.lines) |*it| {
- if (it.next()) |line| {
- const idx = w.line_idx;
- w.line_idx += 1;
- // Lines scrolled off the top still count: the index names a
- // line of the BUFFER, not of the visible body.
- if (w.skip > 0) {
- w.skip -= 1;
- continue;
- }
- w.n += 1;
- return .{ .edit = .{ .line = line, .idx = idx } };
- }
- // The buffer stands in for `rows` shell rows however many lines
- // it actually spelled, which is the whole slide.
- w.g += w.covered;
- w.skip = 0;
- w.lines = null;
- continue;
- }
- if (w.pane.mode != .tty) if (w.pane.ovl) |o| if (w.g == o.row) {
- w.lines = std.mem.splitScalar(u8, o.text, '\n');
- w.covered = o.rows;
- w.line_idx = 0;
- continue;
- };
- const vi = w.g - w.goff;
- w.g += 1;
- w.n += 1;
- return .{ .grid = vi };
- }
- return null;
- }
- };
-
- /// Run the terminal body row walk. A null destination counts bytes; a slice
- /// fills the exact allocation made from that count.
- fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
- var walk: BodyWalk = .init(pane);
- var written: usize = 0;
- var first = true;
- while (walk.next()) |row| {
- if (!first) {
- if (dst) |out| out[written] = '\n';
- written += 1;
- }
- first = false;
- const bytes = switch (row) {
- .edit => |e| e.line,
- .grid => |vi| if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len)
- viewport[@intCast(vi)]
- else
- "",
- };
- if (dst) |out| @memcpy(out[written..][0..bytes.len], bytes);
- written += bytes.len;
- }
- return written;
- }
-
- // Match surviving edit-buffer rows to their original terminal styles.
- const EditAnchors = struct {
- /// the buffer's own text, walked in order: a line the VIEWPORT skipped still
- /// consumes the row it came from, so the lines below it stay aligned
- text: []const u8 = &.{},
- at: usize = 0,
- shell: []const []const u8 = &.{},
- /// the covered span, absolute grid rows, as `[first, end)`
- first: usize = 0,
- end: usize = 0,
- lines: usize = 0,
- /// the line `at` names, and the first row still unclaimed
- idx: usize = 0,
- cursor: usize = 0,
- budget: usize = 0,
- active: bool = false,
-
- fn init(p: *Pardes, pane: *Pane, o: EditBuffer) EditAnchors {
- if (o.rows <= 0 or o.row < 0) return .{};
- const first: usize = @intCast(o.row);
- const screen = pane.terminal.?.vt.screens.active;
- const total = screen.pages.scrollbar().total;
- if (first >= total) return .{};
- const covered: usize = @intCast(o.rows);
- const count = @min(covered, total - first);
- const tl = screen.pages.pin(.{ .screen = .{ .y = @intCast(first) } }) orelse return .{};
- const br = screen.pages.pin(.{ .screen = .{
- .x = screen.pages.cols - 1,
- .y = @intCast(first + count - 1),
- } }) orelse return .{};
- const arena = p.scratch.allocator();
- var output: std.Io.Writer.Allocating = .init(arena);
- screen.dumpString(&output.writer, .{ .tl = tl, .br = br, .unwrap = false }) catch return .{};
- const shell = arena.alloc([]const u8, count) catch return .{};
- var raw = std.mem.splitScalar(u8, output.written(), '\n');
- var pins = tl.rowIterator(.right_down, br);
- for (shell) |*row| {
- const text = raw.next() orelse "";
- row.* = if (pins.next()) |pin|
- if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, text) else text
- else
- text;
- }
- const lines = std.mem.count(u8, o.text, "\n") + 1;
- return .{
- .text = o.text,
- .shell = shell,
- .first = first,
- .end = first + count,
- .lines = lines,
- .cursor = first,
- .budget = covered + 4 * lines,
- .active = true,
- };
- }
-
- fn shellRow(a: *EditAnchors, idx: usize) ?Anchor {
- if (!a.active or idx >= a.lines) return null;
- var found: ?Anchor = null;
- while (a.idx <= idx) : (a.idx += 1) found = a.claim(a.nextLine() orelse return null);
- return found;
- }
-
- fn nextLine(a: *EditAnchors) ?[]const u8 {
- if (a.at > a.text.len) return null;
- const rest = a.text[a.at..];
- if (std.mem.indexOfScalar(u8, rest, '\n')) |n| {
- a.at += n + 1;
- return rest[0..n];
- }
- // The last line has no terminator; one past the end ends the walk.
- a.at = a.text.len + 1;
- return rest;
- }
-
- /// Where this line still stands over the grid, if anywhere.
- fn claim(a: *EditAnchors, line: []const u8) ?Anchor {
- const end = if (line.len == 0) @min(a.cursor + 1, a.end) else a.end;
- var k = a.cursor;
- while (k < end) : (k += 1) {
- if (a.budget == 0) return null;
- a.budget -= 1;
- if (!std.mem.eql(u8, line, a.shell[k - a.first])) continue;
- a.cursor = k + 1;
- return .{ .row = @intCast(k) };
- }
- if (a.cursor >= a.end) return null;
- const shell = a.shell[a.cursor - a.first];
- var p: usize = 0;
- while (p < line.len and p < shell.len and line[p] == shell[p]) p += 1;
- var s: usize = 0;
- const room = @min(line.len, shell.len) - p;
- while (s < room and line[line.len - 1 - s] == shell[shell.len - 1 - s]) s += 1;
- if (p + s == 0) return null;
- if (line.len != p + s and shell.len - (p + s) > shell.len / 2) return null;
- const row = a.cursor;
- if (s > 0 or p >= shell.len) a.cursor += 1;
- return .{ .row = @intCast(row), .prefix = p, .suffix = s, .shell_len = shell.len };
- }
- };
-
- const Anchor = struct {
- /// the row, absolute while it comes from `EditAnchors`, viewport once
- /// `recolorAnsi` has subtracted the walk's offset
- row: i32,
- prefix: usize = std.math.maxInt(usize),
- suffix: usize = 0,
- /// the row's own dumped length — what the suffix is measured from on the
- /// GRID side, where the edit may have changed the byte count
- shell_len: usize = 0,
-
- fn whole(an: Anchor) bool {
- return an.prefix == std.math.maxInt(usize);
- }
- };
-
- pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void {
- // No emulator, no ANSI cells: the whole pass — and the 256-colour theme
- // projection behind it — is compiled out.
- if (comptime !enabled) return;
- const s = &p.surface;
- if (pane.terminal == null) return;
- const body_y = p.bodyTop(r);
- var filtered_storage: FilteredColors = undefined;
- const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
- const tz_filter = tracy.zone(@src(), "filterInit");
- defer tz_filter.end();
- filtered_storage = FilteredColors.init(p, pane);
- break :blk &filtered_storage;
- } else null;
- // DECSCNM, read once: the filtered palette folds it in itself, the raw
- // path needs it per cell.
- const scnm = pane.terminal.?.vt.modes.get(.reverse_colors);
- const pages = &pane.terminal.?.vt.screens.active.pages;
- const vp_rows: i32 = @intCast(scrollbar(pane).len);
- // Which buffer lines the user has not actually changed, so a row swallowed
- // by a growing buffer keeps the colour it still stands over.
- var anchors: ?EditAnchors = null;
- var walk: BodyWalk = .init(pane);
- var lines = std.mem.splitScalar(u8, body, '\n');
- var vr: u16 = 0;
- while (walk.next()) |row| : (vr += 1) {
- if (vr >= body_h) break;
- // Before any early exit below, or the lines fall out of step with rows.
- const text = lines.next() orelse "";
- const anchor: Anchor = switch (row) {
- .edit => |e| blk: {
- if (anchors == null) anchors = .init(p, pane, pane.ovl.?);
- var an = anchors.?.shellRow(e.idx) orelse continue;
- an.row -= walk.goff;
- break :blk an;
- },
- .grid => |v| .{ .row = v },
- };
- const vi = anchor.row;
- if (vi < 0 or vi >= vp_rows) continue;
- const row_pin = pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue;
- const cut: u16 = if (pane.mode == .tty) 0 else cut: {
- if (row_pin.rowAndCell().row.semantic_prompt == .none) break :cut 0;
- break :cut switch (promptCut(row_pin)) {
- .keep => 0,
- // Blanked end to end: the row shows nothing of the grid, so
- // projecting the prompt's own colours onto it would be a lie.
- .blank => continue,
- .cut => |n| std.math.cast(u16, n) orelse continue,
- };
- };
- if (cut > 0 and text.len == 0) continue;
- var at: usize = 0;
- var sc: u16 = 0;
- var gc: u16 = cut;
- var sb: usize = 0;
- const mine_from = @min(anchor.prefix, text.len);
- const mine_to = text.len - @min(anchor.suffix, text.len);
- var crossed = false;
- while (at < text.len and sc < tw) {
- const stop = modal.nextGrapheme(text, at);
- if (stop <= at) break;
- const span: u16 = if (sc + 1 < tw and s.at(tx + sc + 1, body_y + vr).len == 0) 2 else 1;
- if (at >= mine_from and at < mine_to) {
- sc += span;
- at = stop;
- continue;
- }
- if (at >= mine_to and !crossed) {
- crossed = true;
- const upto = anchor.shell_len - @min(anchor.suffix, anchor.shell_len);
- while (sb < upto) {
- const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
- sb += dumpedBytes(row_pin, ci.cell);
- gc = std.math.add(u16, gc, 1) catch break;
- }
- }
- const want = stop - at;
- // Consume every cell that contributed to this grapheme. A cluster
- // ghostty split across several cells is still ONE printed glyph.
- var covered: usize = 0;
- var style: ?pardes.CellStyle = null;
- while (covered < want) {
- const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
- if (style == null and ci.cell.wide != .spacer_tail and ci.cell.wide != .spacer_head)
- style = cellStyle(p, ci, filtered, scnm);
- covered += dumpedBytes(row_pin, ci.cell);
- gc = std.math.add(u16, gc, 1) catch break;
- // A spacer contributes no bytes; without this the loop would
- // spin on a row that ends in one.
- if (covered == 0 and gc >= pane.cols) break;
- }
- while (pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } })) |t| {
- if (t.cell.wide != .spacer_tail) break;
- gc = std.math.add(u16, gc, 1) catch break;
- }
- if (style) |st| for (0..span) |k| {
- const cell = s.at(tx + sc + @as(u16, @intCast(k)), body_y + vr);
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = st;
- };
- sb += covered;
- sc += span;
- at = stop;
- }
- var tail: ?pardes.CellStyle = null;
- if (anchor.whole() or anchor.suffix > 0) {
- while (sc < tw) {
- const ci = pages.getCell(.{ .viewport = .{ .x = gc, .y = @intCast(vi) } }) orelse break;
- gc = std.math.add(u16, gc, 1) catch break;
- if (ci.cell.wide == .spacer_tail) continue;
- const cell = s.at(tx + sc, body_y + vr);
- sc += 1;
- const style = cellStyle(p, ci, filtered, scnm);
- tail = style;
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = style;
- }
- if (tail) |style| while (sc < tw) : (sc += 1) {
- const cell = s.at(tx + sc, body_y + vr);
- if (cell.default and filtered == null) continue;
- cell.default = false;
- cell.style = style;
- };
- }
- }
- }
-
- fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors, scnm: bool) pardes.CellStyle {
- const style = ci.style();
- var cs: pardes.CellStyle = .{
- .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, scnm),
- .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, !scnm),
- .bold = style.flags.bold,
- .dim = style.flags.faint,
- .italic = style.flags.italic,
- .blink = style.flags.blink,
- .reverse = style.flags.inverse,
- .invisible = style.flags.invisible,
- .strikethrough = style.flags.strikethrough,
- .ul = switch (style.flags.underline) {
- .none => .off,
- .single => .single,
- .double => .double,
- .curly => .curly,
- .dotted => .dotted,
- .dashed => .dashed,
- },
- };
- if (filtered == null) switch (ci.cell.content_tag) {
- .bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
- .bg_color_rgb => {
- const rgb = ci.cell.content.color_rgb;
- cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
- },
- else => {},
- };
- return cs;
- }
-
- const FilteredColors = struct {
- source: GColor.Palette,
- target: *const GColor.Palette,
- theme_bg: GColor.RGB,
- theme_fg: GColor.RGB,
- dynamic_bg: ?GColor.RGB,
- dynamic_fg: ?GColor.RGB,
- default_bg: GColor.RGB,
- /// What a foreground too near `default_bg` becomes instead.
- fallback_fg: GColor.RGB,
- default_bg_luminance: f64,
- fg_for_palette: [256]pardes.Color = undefined,
- bg_for_palette: [256]pardes.Color = undefined,
- /// The two answers for a cell that names no colour of its own.
- fg_default: pardes.Color = undefined,
- bg_default: pardes.Color = undefined,
- cache_rgb: [256]GColor.RGB = undefined,
- cache_key: [256]u8 = undefined,
- cache_valid: [256]bool = @splat(false),
-
- fn init(p: *Pardes, pane: *const Pane) FilteredColors {
- var source = GColor.default;
- for (&source, 0..) |*rgb, i|
- rgb.* = pane.terminal.?.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
- const theme = p.theme();
- var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
- var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
- var dynamic_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background });
- var dynamic_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground });
- if (pane.terminal.?.vt.modes.get(.reverse_colors)) {
- std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
- std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
- }
- var self: FilteredColors = .{
- .source = source,
- .target = p.tty_filter_palette.get(theme),
- .theme_bg = theme_bg,
- .theme_fg = theme_fg,
- .dynamic_bg = dynamic_bg,
- .dynamic_fg = dynamic_fg,
- .default_bg = theme_bg,
- .fallback_fg = theme_fg,
- .default_bg_luminance = luminanceOf(theme_bg),
- };
- if (dynamic_bg) |rgb| self.default_bg = self.keyedRgb(rgb);
- self.default_bg_luminance = luminanceOf(self.default_bg);
- if (self.theme_bg.contrast(self.default_bg) > self.theme_fg.contrast(self.default_bg))
- self.fallback_fg = self.theme_bg;
-
- self.fg_default = asPardesColor(self.legible(
- if (self.dynamic_fg) |rgb| self.keyedRgb(rgb) else self.theme_fg,
- ));
- self.bg_default = asPardesColor(self.default_bg);
- for (&self.source, 0..) |current, i| {
- const idx: u8 = @intCast(i);
- const mapped = self.paletteRgb(idx, current);
- self.fg_for_palette[idx] = asPardesColor(self.legible(mapped));
- self.bg_for_palette[idx] = asPardesColor(mapped);
- }
- return self;
- }
-
- fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
- return switch (style.fg_color) {
- .none => self.fg_default,
- .palette => |idx| self.fg_for_palette[idx],
- .rgb => asPardesColor(self.legible(self.keyedRgb(style.fg(.{
- .default = self.dynamic_fg orelse self.theme_fg,
- .palette = &self.source,
- .bold = null,
- })))),
- };
- }
-
- fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
- switch (cell.content_tag) {
- .bg_color_palette => return self.bg_for_palette[cell.content.color_palette.data],
- .bg_color_rgb => {},
- else => switch (style.bg_color) {
- .none => return self.bg_default,
- .palette => |idx| return self.bg_for_palette[idx],
- .rgb => {},
- },
- }
- // Truecolour, from either the cell or its style.
- return asPardesColor(self.keyedRgb(style.bg(cell, &self.source).?));
- }
-
- fn legible(self: *const FilteredColors, rgb: GColor.RGB) GColor.RGB {
- if (contrastOf(luminanceOf(rgb), self.default_bg_luminance) >=
- config.tty_filter_min_contrast) return rgb;
- return self.fallback_fg;
- }
-
- fn paletteRgb(self: *FilteredColors, idx: u8, current: GColor.RGB) GColor.RGB {
- if (current.eql(GColor.default[idx])) return self.target[idx];
- return self.keyedRgb(current);
- }
-
- fn keyedRgb(self: *FilteredColors, rgb: GColor.RGB) GColor.RGB {
- return self.target[self.nearestKey(rgb)];
- }
-
- fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
- const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
- const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
- if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
- return self.cache_key[slot];
-
- var best: u8 = 0;
- var best_distance: u32 = std.math.maxInt(u32);
- for (GColor.default, 0..) |candidate, i| {
- const distance = colorDistance(rgb, candidate);
- // Strict comparison makes duplicate-colour ties stable at the
- // lowest canonical xterm key.
- if (distance < best_distance) {
- best_distance = distance;
- best = @intCast(i);
- }
- }
- self.cache_rgb[slot] = rgb;
- self.cache_key[slot] = best;
- self.cache_valid[slot] = true;
- return best;
- }
- };
-
- const channel_luminance: [256]f64 = blk: {
- @setEvalBranchQuota(20000);
- var table: [256]f64 = undefined;
- for (&table, 0..) |*slot, c| {
- const normalized: f64 = @as(f64, @floatFromInt(c)) / 255;
- slot.* = if (normalized <= 0.03928)
- normalized / 12.92
- else
- std.math.pow(f64, (normalized + 0.055) / 1.055, 2.4);
- }
- break :blk table;
- };
-
- fn luminanceOf(rgb: GColor.RGB) f64 {
- return 0.2126 * channel_luminance[rgb.r] +
- 0.7152 * channel_luminance[rgb.g] +
- 0.0722 * channel_luminance[rgb.b];
- }
-
- /// ghostty's `RGB.contrast` with both luminances already in hand.
- fn contrastOf(a_luminance: f64, b_luminance: f64) f64 {
- const lighter = @max(a_luminance, b_luminance);
- const darker = @min(a_luminance, b_luminance);
- return (lighter + 0.05) / (darker + 0.05);
- }
-
- fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
- const dr = @as(i32, a.r) - @as(i32, b.r);
- const dg = @as(i32, a.g) - @as(i32, b.g);
- const db = @as(i32, a.b) - @as(i32, b.b);
- return @intCast(dr * dr + dg * dg + db * db);
- }
-
- test "the luminance table answers exactly what ghostty computes" {
- for (0..256) |i| {
- const c: u8 = @intCast(i);
- const grey: GColor.RGB = .{ .r = c, .g = c, .b = c };
- try std.testing.expectEqual(grey.luminance(), luminanceOf(grey));
- }
- // Channel weights are asymmetric, so a grey ramp alone would not catch a
- // transposed coefficient. The palette is what the tables enumerate.
- for (GColor.default) |candidate| {
- try std.testing.expectEqual(candidate.luminance(), luminanceOf(candidate));
- for (GColor.default) |page| {
- try std.testing.expectEqual(
- candidate.contrast(page),
- contrastOf(luminanceOf(candidate), luminanceOf(page)),
- );
- }
- }
- }
-
- test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
- const testing = std.testing;
- // Raw index assertions require a theme that inherits the ANSI palette.
- const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- try testing.expect(pane.tty_filter);
- pane.tty_filter = false;
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
- "\x1b[38;5;196mB" ++
- "\x1b[38;2;255;0;0mC" ++
- "\x1b[0;48;5;25mD" ++
- "\x1b[0;48;2;0;95;175mE" ++
- "\x1b[0;1;2;3;4;5;7;8;9mF" ++
- "\x1b[0;48;5;25m\x1b[K" ++
- "\r\n\x1b[0;38;5;2m界" } });
-
- var frame = std.heap.ArenaAllocator.init(testing.allocator);
- defer frame.deinit();
- const r = p.rects[0];
- const tx = r.x + config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
-
- const raw = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
-
- pane.tty_filter = true;
- _ = frame.reset(.retain_capacity);
- const filtered = try p.render(frame.allocator());
- var expected_cache: FilterPalette = .{};
- const expected = expected_cache.get(p.theme());
- try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
- try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
- try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
- try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
- try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
-
- const attrs = filtered.at(tx + 5, body_y).style;
- try testing.expect(attrs.bold);
- try testing.expect(attrs.dim);
- try testing.expect(attrs.italic);
- try testing.expect(attrs.blink);
- try testing.expect(attrs.reverse);
- try testing.expect(attrs.invisible);
- try testing.expect(attrs.strikethrough);
- try testing.expectEqual(.single, attrs.ul);
-
- const erased = pane.terminal.?.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
- try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
- try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
- try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
- try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
- // A filtered terminal never delegates either colour to a backend palette,
- // including cells which were empty/default before the pass.
- for (0..r.w - config.GUTTER) |col| {
- const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
- try testing.expect(!cell.default);
- switch (cell.style.fg) {
- .rgb => {},
- else => return error.FilteredForegroundWasNotRgb,
- }
- switch (cell.style.bg) {
- .rgb => {},
- else => return error.FilteredBackgroundWasNotRgb,
- }
- }
-
- // Colors remains the global master gate. The pane remembers Filter while
- // ANSI projection is dormant, and resumes it without replaying VT bytes.
- p.settings.colors = false;
- _ = frame.reset(.retain_capacity);
- const plain = try p.render(frame.allocator());
- try testing.expect(pane.tty_filter);
- try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
- p.settings.colors = true;
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
- const osc_red = pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?;
- try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
- pane.tty_filter = false;
- pane.tty_filter = true;
- try testing.expect(osc_red.eql(pane.terminal.?.vt.colorForXterm(.{ .palette = 1 }).?));
- _ = frame.reset(.retain_capacity);
- const osc_palette = try p.render(frame.allocator());
- try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
-
- // Dynamic default foreground/background colours key every default cell,
- // including the otherwise blank end of the row.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
- "\x1b]11;#5f5f5f\x1b\\" } });
- const dyn_fg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }).?;
- const dyn_bg = pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }).?;
- try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
- try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
- _ = frame.reset(.retain_capacity);
- const dynamic = try p.render(frame.allocator());
- const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
- try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
-
- const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
- try testing.expectEqual(asPardesColor(expected[196]), explicit_before_reverse);
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
- _ = frame.reset(.retain_capacity);
- const reversed = try p.render(frame.allocator());
- const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
- try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
- const reversed_explicit = reversed.at(tx, body_y).style;
- try testing.expectEqual(asPardesColor(expected[196]), reversed_explicit.bg);
- try testing.expectEqual(pardes.Color{ .rgb = p.theme().bg.? }, reversed_explicit.fg);
- try testing.expect(!std.meta.eql(reversed_explicit.fg, reversed_explicit.bg));
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
- _ = frame.reset(.retain_capacity);
- const unreversed = try p.render(frame.allocator());
- const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
- try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
- try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
-
- // The cache is keyed by values, not a theme name. Replacing a custom
- // theme in place immediately recolours already-rendered indexed cells.
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
- var custom = p.theme().*;
- custom.name = try p.gpa.dupe(u8, "same-name");
- custom.palette = null;
- custom.kw = .{ 1, 2, 3 };
- p.custom_theme = custom;
- _ = frame.reset(.retain_capacity);
- const custom_first = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
- if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
- _ = frame.reset(.retain_capacity);
- const custom_second = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
- }
-
- test "terminal Filter keeps extended keys dark-to-light on a light theme" {
- const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .tty_only = true });
- defer p.deinit();
- try std.testing.expectEqualStrings("acme", p.theme().name);
- var cache: FilterPalette = .{};
- const palette = cache.get(p.theme());
- try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
- try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
- }
-
- test "terminal Filter preserves exact palette-null light theme default roles" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
-
- var light = p.theme().*;
- light.name = try p.gpa.dupe(u8, "filter-light-defaults");
- light.bg = .{ 0xf8, 0xf8, 0xf8 };
- light.fg = .{ 0x38, 0x38, 0x38 };
- light.palette = null;
- p.custom_theme = light;
-
- const pane = p.panes[0].?;
- try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .foreground }));
- try testing.expectEqual(@as(?GColor.RGB, null), pane.terminal.?.vt.colorForXterm(.{ .dynamic = .background }));
- pane.tty_filter = true;
-
- var frame = std.heap.ArenaAllocator.init(testing.allocator);
- defer frame.deinit();
- const r = p.rects[0];
- const tx = r.x + config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- const ordinary = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
- _ = frame.reset(.retain_capacity);
- const reversed = try p.render(frame.allocator());
- try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
- try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
- }
-
- test "terminal Filter maps the default roles before it maps anything else" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- try testing.expect(pane.tty_filter);
-
- // Native dark/light palettes plus legacy helix/dark/acme, including
- // the null-page fallback that only the legacy dark theme exercises.
- for (0..9) |t| {
- p.settings.theme = @intCast(t);
- const stage_one = FilteredColors.init(p, pane);
- // `dark` declares no background of its own, which is exactly why the
- // resolver reads the tag colours as the fallback rather than `.?`.
- const theme = p.theme();
- try testing.expect(stage_one.theme_bg.eql(asGhostRgb(theme.bg orelse theme.tag_bg)));
- try testing.expect(stage_one.theme_fg.eql(asGhostRgb(theme.fg orelse theme.tag_fg)));
- // With no OSC 11 in play the mapped page IS that anchor, and the
- // fallback is the other one: a background never contrasts with itself.
- try testing.expect(stage_one.default_bg.eql(stage_one.theme_bg));
- try testing.expect(stage_one.fallback_fg.eql(stage_one.theme_fg));
- }
-
- p.settings.theme = 0;
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]11;#5f5f5f\x1b\\" } });
- var moved = FilteredColors.init(p, pane);
- try testing.expect(!moved.default_bg.eql(moved.theme_bg));
- try testing.expect(moved.default_bg.eql(moved.keyedRgb(.{ .r = 0x5f, .g = 0x5f, .b = 0x5f })));
- }
-
- test "terminal Filter refuses a foreground that would collapse onto the page" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- try testing.expect(pane.tty_filter);
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[38;2;255;255;255mW" ++
- "\x1b[0;30mB" ++
- "\x1b[0;31mR" } });
-
- var frame = std.heap.ArenaAllocator.init(testing.allocator);
- defer frame.deinit();
- const r = p.rects[0];
- const tx = r.x + config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
-
- for (0..9) |t| {
- p.settings.theme = @intCast(t);
- var fc = FilteredColors.init(p, pane);
- const page = asPardesColor(fc.default_bg);
- const rescued = asPardesColor(fc.fallback_fg);
- _ = frame.reset(.retain_capacity);
- const g = try p.render(frame.allocator());
-
- // The colour each of the three would have been given with no floor.
- const raw_white = fc.keyedRgb(.{ .r = 255, .g = 255, .b = 255 });
- const raw_black = fc.paletteRgb(0, GColor.default[0]);
- const raw_red = fc.paletteRgb(1, GColor.default[1]);
-
- for ([_]struct { at: u16, raw: GColor.RGB }{
- .{ .at = 0, .raw = raw_white },
- .{ .at = 1, .raw = raw_black },
- .{ .at = 2, .raw = raw_red },
- }) |case| {
- const cell = g.at(tx + case.at, body_y).style;
- try testing.expectEqual(page, cell.bg);
- if (case.raw.contrast(fc.default_bg) < config.tty_filter_min_contrast) {
- // Refused: the projection's answer is not painted, the anchor is.
- try testing.expectEqual(rescued, cell.fg);
- try testing.expect(!std.meta.eql(cell.fg, cell.bg));
- } else {
- // Cleared the floor, so stage two leaves it exactly alone.
- try testing.expectEqual(asPardesColor(case.raw), cell.fg);
- }
- // Either way a filtered cell delegates neither colour to a backend.
- switch (cell.fg) {
- .rgb => |ink| try testing.expect(asGhostRgb(ink).contrast(fc.default_bg) >=
- config.tty_filter_min_contrast),
- else => return error.FilteredForegroundWasNotRgb,
- }
- }
-
- // At least one of the three has to have been a real collapse, or this
- // theme proved nothing: white on the light theme, black on the dark.
- try testing.expect(raw_white.contrast(fc.default_bg) < config.tty_filter_min_contrast or
- raw_black.contrast(fc.default_bg) < config.tty_filter_min_contrast);
- // A saturated red is never the page on any curated theme.
- try testing.expect(raw_red.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
- }
- }
-
- test "terminal Filter holds every projected foreground off the page" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
-
- for (0..9) |t| {
- p.settings.theme = @intCast(t);
- var fc = FilteredColors.init(p, pane);
- var refused: usize = 0;
- for (fc.target, 0..) |projected, key| {
- const ink = fc.legible(projected);
- try testing.expect(ink.contrast(fc.default_bg) >= config.tty_filter_min_contrast);
- if (!ink.eql(projected)) {
- refused += 1;
- try testing.expect(ink.eql(fc.fallback_fg));
- // Only ever refused for being too near the page.
- try testing.expect(projected.contrast(fc.default_bg) < config.tty_filter_min_contrast);
- }
- // The key a background asks for is handed back untouched, including
- // the one whose value is the page itself.
- try testing.expect(fc.keyedRgb(GColor.default[key]).eql(fc.target[fc.nearestKey(GColor.default[key])]));
- }
- // Every curated theme owns at least one collapsing key — that is why
- // the floor exists — and the floor must not be flattening the palette.
- try testing.expect(refused > 0);
- try testing.expect(refused < fc.target.len / 8);
- }
- }
-
- test "tty ansi colors follow the prompt hug into normal mode" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- pane.tty_filter = false;
-
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32mPP\x1b]133;B\x1b\\\x1b[31mR\x1b[34mB\x1b[0m out" } });
-
- var frame = std.heap.ArenaAllocator.init(testing.allocator);
- defer frame.deinit();
- const r = p.rects[0];
- const tx = r.x + config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] };
- const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] };
-
- // tty mode projects the emulator's ansi colours cell for cell.
- pane.mode = .tty;
- p.shell_rows.stale = true;
- const tty = try p.render(frame.allocator());
- try testing.expectEqual(red, tty.at(tx + 2, body_y).style.fg);
- try testing.expectEqual(blue, tty.at(tx + 3, body_y).style.fg);
-
- pane.mode = .normal;
- p.shell_rows.stale = true;
- _ = frame.reset(.retain_capacity);
- const norm = try p.render(frame.allocator());
- try testing.expectEqualStrings("R", norm.at(tx, body_y).grapheme());
- try testing.expectEqualStrings("B", norm.at(tx + 1, body_y).grapheme());
- try testing.expectEqual(red, norm.at(tx, body_y).style.fg);
- try testing.expectEqual(blue, norm.at(tx + 1, body_y).style.fg);
- }
-
- test "a prompt row hidden end to end paints nothing at all" {
- const testing = std.testing;
- const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 8 });
- defer p.deinit();
- while (p.nextEffect()) |_| {}
- const pane = p.panes[0].?;
- pane.tty_filter = false;
- pane.mode = .normal;
- p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x1b\\\x1b[32maaaaaaaaa\x1b]133;B\x1b\\\x1b[41;36m\u{754C}\x1b[0m\r\n" } });
-
- var frame = std.heap.ArenaAllocator.init(testing.allocator);
- defer frame.deinit();
- const r = p.rects[0];
- const tx = r.x + config.GUTTER;
- const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
- p.shell_rows.stale = true;
- const s = try p.render(frame.allocator());
-
- try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme());
- try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg));
- }
-
- pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
- if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
- return .{ .index = idx };
- }
-
- pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes.Color {
- return switch (color) {
- .none => blk: {
- const t = if (is_bg) p.theme().bg else p.theme().fg;
- break :blk if (t) |c| .{ .rgb = c } else .default;
- },
- .palette => |idx| palColor(p, idx),
- .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
- };
- }
-
- /// executing at a prompt with typed text below it: pad the output area
- /// with newlines so the command's output doesn't overwrite the buffer
- pub fn padOutputBelowEdits(p: *Pardes, id: usize) void {
- // Nothing to pad away from: with no emulator there is no prompt and no
- // child whose output could land on top of the edit buffer.
- if (comptime !enabled) return;
- const pane = p.panes[id] orelse return;
- const o = pane.ovl orelse return;
- if (!pane.isTerminal()) return;
- const state = pane.terminal orelse return;
- if (!state.vt.cursorIsAtPrompt()) return;
- // the buffer's LAST surface row: its lines may outnumber the shell
- // rows it covers, and it is the bottom one output must clear
- const max_row = o.row + @as(i32, @intCast(modal.lineCount(o.text))) - 1;
- const goff: i32 = @intCast(state.vt.screens.active.pages.scrollbar().offset);
- const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(state.vt.screens.active.cursor.y)));
- const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
- var i: i32 = 0;
- while (i < pad) : (i += 1) p.emitWrite(id, "\r");
- }
-
- /// the snapshot takes ownership of a COPY of the edit buffer's text
- 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: Snapshot) void {
- if (pane.ovl) |o| p.gpa.free(o.text);
- pane.ovl = s.ovl;
- pane.cur_row = s.cur_row;
- pane.cur_col = s.cur_col;
- pane.cur_pinned = true;
- pane.vsel = s.vsel;
- pane.msel.active = false;
- 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]);
- }
-
- pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
- const state: *State = @alignCast(@fieldParentPtr("vt", handler.terminal));
- const room = state.reply.len - state.reply_len;
- const n = @min(room, data.len);
- @memcpy(state.reply[state.reply_len..][0..n], data[0..n]);
- state.reply_len += @intCast(n);
- }
-
- const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
- @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
- ).optional.child).pointer.child).@"fn".return_type.?;
- pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
- return .{};
- }
-};
+pub const Terminal = @import("Terminal.zig");
test {
_ = Pane;