//! Terminal panes: everything a Pane does BECAUSE it owns a ghostty-vt //! emulator — constructing and replaying the emulator, reading its grid back //! out as text (for motions and for the body), translating its cell styles //! into ours, handling the pty replies it hands back through a callback, and //! keeping the edit-buffer undo snapshots that only exist because a terminal's //! "content" is a live grid rather than a []u8. //! //! Shared modal edit semantics and the pane-wide screen/grid coordinate //! invariants remain on Pane in pardes.zig. Terminal-only projection, history //! snapshots, and cell styling live here, so the core does not need to know //! how a live terminal becomes an editable text surface. //! //! ...and because it does not, this is the only CORE file that ever holds a //! ghostty-vt VALUE: pardes.zig no longer imports the emulator at all, and //! image.zig's import exists solely to comptime-check a colour table against //! it. (src/gui/gui.zig and the test/ snapshot harness import it too — both //! are backends, and neither is in the p4 graph.) `pardes.terminal_panes` says //! whether a build has an emulator at all; the two Pane slots and the //! accessors under "the emulator, as the core is allowed to see it" are the //! whole seam, and `!enabled` answers every one of them with the empty grid. //! See pardes.terminal_panes for why the P4 firmware has none. const std = @import("std"); const pardes = @import("pardes.zig"); const Pardes = pardes.Pardes; const Pane = pardes.Pane; const Key = pardes.Key; const EditText = pardes.EditText; const modal = @import("modal.zig"); const config = @import("config.zig"); const dump = @import("dump.zig"); /// `pardes.terminal_panes`, re-exported so every gate in this file reads one /// local name. When false the import below is a DEAD comptime branch, so /// build.zig need not resolve the ghostty dependency at all. pub const enabled = pardes.terminal_panes; const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; /// EDIT-BUFFER BOUNDARIES REMEMBERED PER PANE. Snapshots copy the whole edit /// buffer, so keep this tighter than files. /// /// A CAPACITY, not a presence: without an emulator `pane.ovl` is not a typed /// overlay on a live grid, it is the pane's ENTIRE content (see `create` and /// `restore` below), so undo on it matters more here, not less. But each entry /// is a gpa copy of that content, and 64 of them is 2.5 KiB of `Pane` plus 64 /// heap copies — on a board with a 384 KiB heap the ring would run out of /// memory long before it ran out of slots. `pushHistory` evicts and frees the /// oldest once full, so the shorter ring loses only the deepest undo steps. pub const history_max = if (enabled) 64 else 8; /// The emulator and its VT parser as PANE FIELDS — the `PdfSlot` pattern from /// pardes.zig, zero-sized where there are no terminal panes. Declared here /// rather than there so the emulator's type never has to be named by the core. pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; pub const StreamSlot = if (enabled) ghostty_vt.TerminalStream else void; const GColor = ghostty_vt.color; /// The inputs which completely determine a filtered terminal palette. Theme /// names and indices are intentionally absent: ThemeFile may replace a theme /// in place under the same name, while equal colour values need no rebuild. const FilterPaletteKey = struct { bg: GColor.RGB, fg: GColor.RGB, base: [16]GColor.RGB, }; /// Cached 256-colour projection of the current Pardes theme. Ghostty owns the /// interpolation: its CIELAB cube and greyscale ramp give every xterm key a /// theme-derived RGB value while retaining the conventional dark-to-light /// index orientation on light themes (`harmonious = false`). /// /// Zero-sized without an emulator — there are no ANSI cells to reproject, so /// `Pardes.tty_filter_palette` costs the core nothing but keeps its `.{}`. 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 { // A theme without an ANSI table still supplies every key. The // first eight keep the usual semantic families; their bright // partners use the same accents plus the theme's lighter chrome. 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: pardes.CharSel, }; /// Command bytes aimed at a shell whose prompt does not exist yet. The host /// resolves the actual executable after the core has already queued `.spawn`, /// so `spawn` is deliberately an UNKNOWN phase: shells with prompt integration /// advance to `input` and wait for OSC 133 B; unadorned/unsupported shells are /// opened immediately by `shellSpawned` and let the pty buffer input until the /// child reads it. /// /// This is pane state, not a Pardes-wide job table. A reused pane slot can /// therefore never inherit a command intended for the shell it replaced. pub const PendingCommand = struct { bytes: []u8 = &.{}, wait: enum { none, spawn, input } = .none, }; /// A terminal constructed by `newShell` cannot safely receive a command until /// the native host has at least completed forkpty. Dump-replay terminals do /// not call this: they are dead grids, not half-spawned children. pub fn armShellSpawn(pane: *Pane) void { // With no emulator there is no fork to wait on and no OSC 133 that could // ever arrive, so the gate stays open: `queuePendingCommand` declines and // the command leaves as an ordinary write, rather than waiting forever. if (comptime !enabled) return; std.debug.assert(pane.pending_command.bytes.len == 0); pane.pending_command.wait = .spawn; } /// Own `command` until this pane's new child can accept it. False means the /// gate is already open and the caller should emit in the ordinary way. /// Multiple gestures before the prompt appears retain their byte order in one /// flat allocation; each command gets exactly the CR execute normally emits. pub fn queuePendingCommand(pane: *Pane, command: []const u8) !bool { if (pane.pending_command.wait == .none) return false; const old_len = pane.pending_command.bytes.len; const new_len = try std.math.add(usize, old_len, try std.math.add(usize, command.len, 1)); const bytes = if (old_len == 0) try pane.gpa.alloc(u8, new_len) else try pane.gpa.realloc(pane.pending_command.bytes, new_len); @memcpy(bytes[old_len..][0..command.len], command); bytes[new_len - 1] = '\r'; pane.pending_command.bytes = bytes; // An explicit command replaces the automatic greeting. Otherwise both // would be released by the same first prompt and `ls` would follow what // the user actually asked to run. pane.greet = false; return true; } /// The host successfully forked `pane`. `prompt_marks` describes the argv it /// ACTUALLY used, not the configured shell name: a staged-rc failure or an /// unsupported family is an honest unmarked shell and must not wait forever /// for an OSC sequence it cannot produce. pub fn shellSpawned(p: *Pardes, id: usize, prompt_marks: bool) void { const pane = p.panes[id] orelse return; if (!pane.isTerminal() or pane.pending_command.wait != .spawn) return; if (prompt_marks) { pane.pending_command.wait = .input; releasePendingCommand(p, id, pane, false); } else { // There is no semantic event on which an automatic greeting can be // safely based. Explicit commands still release below (the pty owns // their buffering); silently omit the cosmetic `ls` rather than race // an unknown shell's startup and possibly type into its rc program. pane.greet = false; releasePendingCommand(p, id, pane, true); } } /// Called from the ordinary sync after terminal output has been parsed. The /// semantic cursor is ghostty-vt's parsed OSC state, so this and the greeting /// share the exact same definition of "readline owns input". pub fn releasePendingCommandIfReady(p: *Pardes, id: usize, pane: *Pane) void { if (pane.pending_command.wait == .input) releasePendingCommand(p, id, pane, promptInputReady(pane)); } fn releasePendingCommand(p: *Pardes, id: usize, pane: *Pane, ready: bool) void { if (!ready) return; const bytes = pane.pending_command.bytes; pane.pending_command = .{}; if (bytes.len > 0) { p.emitWrite(id, bytes); pane.gpa.free(bytes); } } pub fn deinitPendingCommand(pane: *Pane) void { if (pane.pending_command.bytes.len > 0) pane.gpa.free(pane.pending_command.bytes); pane.pending_command = .{}; } /// The emulator stores an Io value for optional kitty-image work. The browser /// has no host IO and must not instantiate std.Io.Threaded's POSIX backend /// merely to construct a replay-only terminal. pub fn terminalIo() std.Io { return if (comptime !pardes.hosted) std.Io.failing else std.Io.Threaded.global_single_threaded.io(); } // ---- the emulator, as the core is allowed to see it ---- // // Every question pardes.zig used to answer by walking `pane.vt.screens.active` // for itself, named. That is the boundary this file's header always claimed, // and naming them is what lets a build with no emulator answer ALL of them at // comptime with the empty grid, instead of scattering one platform test // through the core's scroll, cursor, mouse, resize and render paths. /// 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 sb = pane.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 viewport 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 cur = pane.vt.screens.active.cursor; return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) }; } /// Move the emulator's viewport by `delta` shell rows (negative scrolls back). pub fn scrollGrid(pane: *Pane, delta: i32) void { if (comptime !enabled) return; pane.vt.screens.active.scroll(.{ .delta_row = delta }); } /// Snap the viewport back onto live output. pub fn followOutput(pane: *Pane) void { if (comptime !enabled) return; pane.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; pane.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; return pane.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 m = &pane.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; return pane.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 &.{}; return pane.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; pane.stream.deinit(); pane.vt.deinit(gpa); } /// Allocate the live emulator half of a terminal pane. Slot ownership, serial /// assignment, and spawn effects remain core lifecycle invariants. pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { const pane = try gpa.create(Pane); errdefer gpa.destroy(pane); if (comptime !enabled) { // No emulator: the pane is a plain text surface whose whole content is // its edit buffer. `tty_filter` stays off — there are no ANSI cells to // reproject and `recolorAnsi` is compiled out entirely. pane.* = .{ .vt = {}, .stream = {}, .gpa = gpa, .cols = cols, .rows = rows }; return pane; } pane.* = .{ .vt = try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = cols, .rows = rows, .max_scrollback = 16 * 1024 * 1024, }), .stream = undefined, .gpa = gpa, .cols = cols, .rows = rows, // Real terminals start theme-keyed. Document panes use the separate // 1x1 stub constructor and retain Pane's inert false default. .tty_filter = true, }; pane.stream = pane.vt.vtStream(); // Answer terminal queries (DSR/DA/kitty) back into the pty, else // crossterm apps (nushell, helix, fzf) block on the reply forever. pane.stream.handler.effects.write_pty = ptyReport; pane.stream.handler.effects.device_attributes = ptyDeviceAttrs; return pane; } /// A doc pane (file/image/PDF): no pty and no spawn, and a stub 1x1 emulator /// only because the shared pane machinery touches its allocator-owned bits. /// Slot registration stays with the core, as for `create`. pub fn createDoc(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { const pane = try gpa.create(Pane); errdefer gpa.destroy(pane); pane.* = .{ .vt = if (comptime enabled) try ghostty_vt.Terminal.init(terminalIo(), gpa, .{ .cols = 1, .rows = 1 }) else {}, .stream = undefined, .gpa = gpa, .cols = cols, .rows = rows, }; if (comptime enabled) pane.stream = pane.vt.vtStream(); return pane; } /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy /// stay with the core; raw VT replay and viewport restoration belong here. pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { const terminal = src.terminal.?; if (comptime !enabled) { // Nothing to replay the recorded VT bytes INTO. The dump also carries // the rendered text of that grid, so it becomes the pane's edit buffer // — the one content a build with no emulator can show at all. 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; } const bytes = if (terminal.stream_b64.len > 0) try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) else &.{}; const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); if (bytes.len > 0) { ingest(pane, bytes); 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 { if (bytes.len >= pane.tty_stream.len) { const kept = bytes[bytes.len - pane.tty_stream.len ..]; @memcpy(pane.tty_stream[0..], kept); pane.tty_stream_head = 0; pane.tty_stream_len = pane.tty_stream.len; } else { const overflow = bytes.len -| (pane.tty_stream.len - pane.tty_stream_len); pane.tty_stream_head = (pane.tty_stream_head + overflow) % pane.tty_stream.len; pane.tty_stream_len -= overflow; const tail = (pane.tty_stream_head + pane.tty_stream_len) % pane.tty_stream.len; const first = @min(bytes.len, pane.tty_stream.len - tail); @memcpy(pane.tty_stream[tail..][0..first], bytes[0..first]); @memcpy(pane.tty_stream[0 .. bytes.len - first], bytes[first..]); pane.tty_stream_len += bytes.len; } pane.stream.nextSlice(bytes); } /// Return the replay ring in chronological order. Wrapped records are copied /// into `allocator`; contiguous records remain a borrowed slice of the pane. fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 { if (pane.tty_stream_len == 0) return &.{}; if (pane.tty_stream_head + pane.tty_stream_len <= pane.tty_stream.len) return pane.tty_stream[pane.tty_stream_head..][0..pane.tty_stream_len]; const out = try allocator.alloc(u8, pane.tty_stream_len); const first = pane.tty_stream.len - pane.tty_stream_head; @memcpy(out[0..first], pane.tty_stream[pane.tty_stream_head..]); @memcpy(out[first..], pane.tty_stream[0 .. pane.tty_stream_len - first]); return out; } /// Record and parse one live pty read, invalidate its motion surface, and /// follow it only when the body (possibly parked under a tag edit) is raw. pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { // There are no pty reads at all without an emulator to parse them into. if (comptime !enabled) return; 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) followOutput(pane); } /// True only after OSC 133 B ended the prompt and handed the cursor to shell /// input. `cursorIsAtPrompt` deliberately also accepts OSC A's `.prompt` /// phase; that is right for navigation but too early to inject a greeting — /// readline may not own echo yet and would leave `ls` on an unmarked row. pub fn promptInputReady(pane: *const Pane) bool { if (comptime !enabled) return false; return pane.vt.screens.active_key != .alternate and pane.vt.screens.active.cursor.semantic_content == .input; } test "fresh-shell greeting waits for OSC 133 B input phase" { if (pardes.platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); const pane = p.panes[0].?; while (p.nextEffect()) |_| {} // initial spawn p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.GreetedBeforeOutput, else => {}, }; p.update(.{ .output = .{ .pane = 0, .bytes = "startup banner\r\n" } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.GreetedBeforePrompt, else => {}, }; p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;A\x07prompt$ " } }); try std.testing.expect(!promptInputReady(pane)); while (p.nextEffect()) |effect| switch (effect) { .write => return error.GreetedDuringPrompt, else => {}, }; p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); try std.testing.expect(promptInputReady(pane)); var greeted = false; while (p.nextEffect()) |effect| switch (effect) { .write => |write| greeted = greeted or std.mem.eql(u8, write.bytes.slice(), "ls\r"), else => {}, }; try std.testing.expect(greeted); try std.testing.expect(!pane.greet); } test "fresh-shell commands preserve order and wait for OSC 133 B" { if (pardes.platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} // the host has not acknowledged spawn yet try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo first")); try std.testing.expectEqual(@as(?usize, 0), p.execute(0, "echo second")); while (p.nextEffect()) |effect| switch (effect) { .write => return error.CommandEscapedBeforeFork, else => {}, }; p.acknowledgeShell(0, "/bin/bash", true); while (p.nextEffect()) |effect| switch (effect) { .write => return error.CommandEscapedBeforePrompt, else => {}, }; p.update(.{ .output = .{ .pane = 0, .bytes = "startup\r\n\x1b]133;A\x07prompt$ " } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.CommandEscapedDuringPrompt, else => {}, }; p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]133;B\x07" } }); var sent: [64]u8 = undefined; var sent_len: usize = 0; while (p.nextEffect()) |effect| switch (effect) { .write => |write| { const bytes = write.bytes.slice(); @memcpy(sent[sent_len..][0..bytes.len], bytes); sent_len += bytes.len; }, else => {}, }; try std.testing.expectEqualStrings("echo first\recho second\r", sent[0..sent_len]); try std.testing.expectEqual(.none, p.panes[0].?.pending_command.wait); } test "unmarked fresh shells omit the automatic greeting" { if (pardes.platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{}); defer p.deinit(); const pane = p.panes[0].?; while (p.nextEffect()) |_| {} try std.testing.expect(pane.greet); p.acknowledgeShell(0, "/bin/sh", false); try std.testing.expect(!pane.greet); try std.testing.expectEqual(.none, pane.pending_command.wait); p.update(.{ .output = .{ .pane = 0, .bytes = "plain prompt$ " } }); while (p.nextEffect()) |effect| switch (effect) { .write => return error.UnmarkedGreetingEscaped, else => {}, }; } /// Encode one key for the program that owns a raw terminal and queue its pty /// write. Global chords and mode routing have already been handled by core. pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { var control: [1]u8 = undefined; const bytes: ?[]const u8 = blk: { if (key.ctrl) { if (key.cp >= 'a' and key.cp <= 'z') { control[0] = @intCast(key.cp - 0x60); break :blk control[0..1]; } // ASCII @, A-Z, [, \, ], ^ and _ are one contiguous control range. if (key.cp >= '@' and key.cp <= '_') { control[0] = @intCast(key.cp - 0x40); break :blk control[0..1]; } } if (key.text.len > 0) break :blk key.text; break :blk switch (key.cp) { Key.enter => "\r", Key.backspace => "\x7f", Key.tab => "\t", Key.escape => "\x1b", Key.up => "\x1b[A", Key.down => "\x1b[B", Key.right => "\x1b[C", Key.left => "\x1b[D", Key.delete => "\x1b[3~", else => null, }; }; if (bytes) |encoded| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(encoded) } }); } /// Enter raw tty, handing a pinned modal cursor back to the shell prompt when /// OSC 133 marks one. The visible prompt row is left-hugged outside tty mode, /// so translate its column through the hidden prompt before asking ghostty for /// the arrow-key movement the child understands. pub fn enterTty(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; // Only the PROMPT HANDOFF needs the emulator; the mode switch below is // plain pane state, so a build without one still has a raw mode — it just // has no prompt to translate a pinned cursor back onto. if (comptime enabled) if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: { const screen = pane.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; pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; // A pinned row scrolls away. Raw mode must follow the program's live // cursor, and Last must not restore a stale modal spot on the way back. pane.cur_pinned = false; pane.select = false; pane.append_at = null; pane.sticky_col = -1; pane.pending = 0; } test "raw terminal keys encode text controls and special sequences" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); while (p.nextEffect()) |_| {} const Case = struct { key: Key, expected: ?[]const u8 }; const cases = [_]Case{ .{ .key = .{ .cp = 'é', .text = "é" }, .expected = "é" }, .{ .key = .{ .cp = 'c', .text = "c", .ctrl = true }, .expected = "\x03" }, .{ .key = .{ .cp = 'C', .text = "C", .ctrl = true }, .expected = "\x03" }, .{ .key = .{ .cp = '@', .text = "@", .ctrl = true }, .expected = "\x00" }, .{ .key = .{ .cp = '_', .text = "_", .ctrl = true }, .expected = "\x1f" }, .{ .key = .{ .cp = '1', .text = "1", .ctrl = true }, .expected = "1" }, .{ .key = .{ .cp = Key.up, .alt = true, .shift = true }, .expected = "\x1b[A" }, .{ .key = .{ .cp = Key.delete }, .expected = "\x1b[3~" }, .{ .key = .{ .cp = Key.home }, .expected = null }, }; for (cases) |case| { forwardKey(p, 0, case.key); const expected = case.expected orelse { try std.testing.expect(p.nextEffect() == null); continue; }; const effect = p.nextEffect() orelse return error.MissingWriteEffect; switch (effect) { .write => |write| { try std.testing.expectEqual(@as(u8, 0), write.pane); try std.testing.expectEqualStrings(expected, write.bytes.slice()); }, else => return error.UnexpectedEffect, } try std.testing.expect(p.nextEffect() == null); } } /// The memo behind `shellRows`. ONE entry for the editor, because the motion /// surface is built for the pane the cursor is in and a second pane asking /// would only double a multi-megabyte buffer for a slot it is about to lose /// again. gpa-owned rather than scratch-arena: the whole point is to outlive /// the update that built it. /// /// LIFETIME, the part that would rot silently: `rows` is handed out to /// callers, so the buffers are freed in exactly two places — `sweep`, at the /// TOP of an update before any handler can be holding them, and `reset` when /// the editor goes away. Everything that notices the entry has gone bad /// (output arrived, the grid reflowed, the pane died, another pane wants the /// slot) only marks it `stale`; nothing frees mid-update. That is the same /// guarantee the scratch arena gave, spelled out. 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; } /// `pane` is being destroyed. Drop the pointer now — a freed pane's /// address can come back from the allocator as a different pane, and an /// entry still naming it would answer for the wrong grid — but leave the /// buffers to the next sweep, as ever. 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, }; /// The motion surface of a pane with no emulator behind it: exactly the one /// blank row `buildRows` retains from a real grid, so surface row 0 exists and /// every motion, edit and undo path measures the same thing it always did. 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, }; /// The prompt and the command typed at it share a grid row, and OSC 133 marks /// them apart CELL by cell (`Cell.semantic_content` is output / input / /// prompt). The row flag every caller tests first is only ghostty's "some cell /// in here is a prompt cell" index; taking the row on that flag alone is what /// used to throw the command away with the prompt. 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 }; } /// That decision applied to `raw`, the line ghostty dumped for `pin`'s row. /// Always a slice OF `raw` — dropping the prompt is a left-hug, so the command /// starts at column 0 with no run of blanks in front of it where the prompt /// used to be, and there is nothing to allocate or copy anywhere. /// /// Walking the dump rather than rebuilding the row out of cells keeps ghostty /// the single authority on how a cell spells itself — wide glyphs, combining /// marks and all. One non-spacer cell is one dumped grapheme, and that is what /// makes the cell walk and the byte walk stay in step. 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]; var cps: usize = 1; if (pin.grapheme(cell)) |extra| cps += extra.len; for (0..cps) |_| at = modal.nextGrapheme(raw, at); // 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"); } /// A terminal's shell rows as the surface sees them: the WHOLE /// history+active grid, prompt rows blanked (OSC 133), absolute grid rows /// from 0. The raw material the motion surface is composed from — the /// edit buffer is NOT applied here, so it is also what seeding the buffer /// reads. /// ghostty's dump trims the grid's trailing blank rows; ONE of them is /// kept back, the row the cursor sits on below the last line of output. /// That row is a file's final newline: without it the surface would have /// one line fewer than the same text in a document, and every motion and /// linewise edit at the bottom would diverge. /// /// Building it is O(scrollback) — a dump of the whole history — and a /// keystroke asks for it once or twice, so the result is memoized against the /// pane until its grid changes. A pane sitting on 16 MiB of agent transcript /// paid that dump per press of `j` before the cache; now it pays it once per /// chunk of output. pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { // With no emulator there is no history to dump, and no cache to keep it // in: one empty row, which is the same row `buildRows` keeps back from // ghostty's trimmed dump — a file's final newline. Everything above the // grid (the edit overlay, its undo stacks, every motion) works unchanged // over it, so a pane on the board is an ordinary scratch buffer. if (comptime !enabled) return &empty_grid; const c = &p.shell_rows; if (!c.stale and c.pane == pane) return c.rows; if (c.pane != null) { // Another pane holds the slot. Take it for the NEXT update (the sweep // frees what is there) and answer this one from scratch: whoever owns // the live entry may still be holding the rows it handed out. 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); p.gpa.free(overlay.text); overlay.text = new; } /// Serialize terminal-only state; the core supplies shared pane metadata. pub fn dumpPane( pane: *Pane, arena: std.mem.Allocator, tag: []const u8, body: []const u8, scroll: usize, ) !dump.Pane { if (comptime !enabled) { // A pane with no emulator has no grid to serialize and no VT bytes to // record — its edit buffer IS its content, so that is what the dump // carries, and `restore` reads it straight back into a fresh buffer. 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, .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.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} }); const extra = if (pane.ovl) |overlay| overlay.text.len else 0; const stream = try arena.alloc(u8, try std.math.add(usize, full.len, extra)); var len: usize = 0; var lines = std.mem.splitAny(u8, full, "\n"); var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); var row: i32 = 0; var skip: i32 = 0; while (lines.next()) |raw| : (row += 1) { // Hidden overlay rows still consume prompt pins to keep them aligned. const prompt = if (pane.mode != .tty) prompts.next() else null; if (skip > 0) { skip -= 1; continue; } if (row > 0) { stream[len] = '\n'; len += 1; } if (pane.mode != .tty) if (pane.ovl) |overlay| if (row == overlay.row) { @memcpy(stream[len..][0..overlay.text.len], overlay.text); len += overlay.text.len; skip = overlay.rows - 1; continue; }; const shown = if (prompt) |pin| if (pin.rowAndCell().row.semantic_prompt != .none) promptRow(pin, raw) else raw else raw; @memcpy(stream[len..][0..shown.len], shown); len += shown.len; } return .{ .kind = .terminal, .tag = tag, .body = body, .scroll = scroll, .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream[0..len], .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), .cursor = .{ .col = pane.vt.screens.active.cursor.x, .row = pane.vt.screens.active.cursor.y, }, }, }; } fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows { const full = try pane.vt.screens.active.dumpStringAlloc(p.scratch.allocator(), .{ .screen = .{} }); var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null); // 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 }; } /// The body a terminal renders: the viewport's shell rows (prompt rows blanked /// outside tty mode) with the edit buffer's lines standing in for the rows it /// covers, so what you see is what the motions move over. pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { // The VIEWPORT half is the emulator's; the row walk below is the edit // buffer's and is shared. With no emulator the viewport is simply empty, // and `fillBody` renders the overlay against blank rows. const vp: []const []const u8 = if (comptime !enabled) &.{} else vp: { const raw = try pane.vt.plainString(arena); var prompts = pane.vt.screens.active.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; } /// 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 { const goff: i32 = gridOffset(pane); const off = pane.scroll(); var g: i32 = pane.gridRow(off); // the buffer can start above the viewport: drop the lines scrolled past var skip: usize = if (pane.ovl) |o| @intCast(@max(0, off - pane.surfRow(o.row))) else 0; var written: usize = 0; var n: usize = 0; while (n < pane.rows) { if (pane.mode != .tty) if (pane.ovl) |o| if (g == o.row) { var bit = std.mem.splitScalar(u8, o.text, '\n'); var k: usize = 0; while (bit.next()) |ln| : (k += 1) { if (k < skip) continue; if (n >= pane.rows) break; if (n > 0) { if (dst) |out| out[written] = '\n'; written += 1; } if (dst) |out| @memcpy(out[written..][0..ln.len], ln); written += ln.len; n += 1; } skip = 0; g += o.rows; continue; }; if (n > 0) { if (dst) |out| out[written] = '\n'; written += 1; } const vi = g - goff; if (vi >= 0 and @as(usize, @intCast(vi)) < viewport.len) { const line = viewport[@intCast(vi)]; if (dst) |out| @memcpy(out[written..][0..line.len], line); written += line.len; } n += 1; g += 1; } return written; } /// tty colors: recolor each visible body cell from the emulator's own style so /// raw output keeps its ansi colors. Runs ONLY in tty mode (the caller gates /// it) and reads the live viewport row for row: normal/insert editing shows /// plain text, so nothing an edit does can move a shell row's colour. pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) 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; const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; var filtered_storage: FilteredColors = undefined; const filtered: ?*FilteredColors = if (pane.tty_filter) blk: { filtered_storage = FilteredColors.init(p, pane); break :blk &filtered_storage; } else null; var vr: u16 = 0; while (vr < body_h and vr < pane.rows) : (vr += 1) { var c: u16 = 0; while (c < tw) : (c += 1) { const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue; // Ghostty gives a wide glyph's spacer tail the head's style id; // ordinary projection leaves it, a filter must repaint it too. if (ci.cell.wide == .spacer_tail and filtered == null) continue; const cell = s.at(tx + c, body_y + vr); // sparse projection: bodyText already painted every glyph, so only // a filter (which theme-keys blank/default cells too) touches these. if (cell.default and filtered == null) continue; cell.default = false; cell.style = cellStyle(p, ci, filtered); } } } fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors) pardes.CellStyle { const style = ci.style(); var cs: pardes.CellStyle = .{ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, false), .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, true), .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, }, }; // Style.bg above already resolves Ghostty's color-only cell variants for // the filtered path. Preserve the established direct translation outside // it, where indexed colours are intentionally allowed to reach the host. 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; } /// Per-render resolver. The source palette is materialized through Ghostty's /// public xterm API, so OSC 4 changes participate without reaching into the /// emulator's private state. Truecolour and visually overridden entries are /// reduced to the nearest canonical Ghostty palette key; the key then indexes /// the theme palette. Repeated RGBs pay that search only once per frame. 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, // Direct-mapped rather than append-only: a frame which encounters more // than the cache's capacity must not strand every later (and repeated) // colour on the 256-entry nearest-key scan. The RGB hash spreads the // common 6x6x6 cube values instead of keying on their low bits. 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.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.vt.colorForXterm(.{ .dynamic = .background }); var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }); // DECSCNM swaps only the terminal's default color roles; explicit SGR // colors stay explicit. Reuse Ghostty's parsed mode instead of trying // to infer the escape from cells, just as its renderer does. if (pane.vt.modes.get(.reverse_colors)) { std.mem.swap(GColor.RGB, &theme_bg, &theme_fg); std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg); } return .{ .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, }; } fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color { const resolved = style.fg(.{ .default = self.dynamic_fg orelse self.theme_fg, .palette = &self.source, .bold = null, }); return switch (style.fg_color) { .none => if (self.dynamic_fg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_fg), .palette => |idx| self.palette(idx, resolved), .rgb => self.keyed(resolved), }; } fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color { const resolved = style.bg(cell, &self.source); return switch (cell.content_tag) { .bg_color_palette => self.palette(cell.content.color_palette.data, resolved.?), .bg_color_rgb => self.keyed(resolved.?), else => switch (style.bg_color) { .none => if (self.dynamic_bg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_bg), .palette => |idx| self.palette(idx, resolved.?), .rgb => self.keyed(resolved.?), }, }; } /// Preserve an ordinary indexed colour's semantic key. A value changed by /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way /// as truecolour. Setting an entry to its exact original value is visually /// indistinguishable and correctly takes this fast path. fn palette(self: *FilteredColors, idx: u8, current: GColor.RGB) pardes.Color { if (current.eql(GColor.default[idx])) return asPardesColor(self.target[idx]); return self.keyed(current); } fn keyed(self: *FilteredColors, rgb: GColor.RGB) pardes.Color { const key = self.nearestKey(rgb); return asPardesColor(self.target[key]); } 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; } }; 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 "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" { 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); pane.tty_filter = false; // 1: ANSI base key; 196: extended key; true red exactly matches canonical // key 196. The two backgrounds repeat key 25 as indexed and truecolour. // Erase-to-EOL under that background makes Ghostty color-only cells. 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.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; // OSC 4 changes the value behind an existing indexed cell. Filter treats // that arbitrary value like truecolour, while toggling remains purely a // presentation operation and cannot alter Ghostty's query answer. p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } }); const osc_red = pane.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.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.vt.colorForXterm(.{ .dynamic = .foreground }).?; const dyn_bg = pane.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); // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for // the dynamic/default roles while leaving an explicit ANSI foreground on // an existing cell bound to the same semantic palette key. const explicit_before_reverse = dynamic.at(tx, body_y).style.fg; 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); try testing.expectEqual(explicit_before_reverse, reversed.at(tx, body_y).style.fg); 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; p.custom_theme_active = true; _ = 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, .{ .tty_only = true }); defer p.deinit(); // Curated order is a public theme contract: helix, dark, acme. p.settings.theme = 2; 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; p.custom_theme_active = true; const pane = p.panes[0].?; try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground })); try testing.expectEqual(@as(?GColor.RGB, null), pane.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 "tty ansi colors render only in tty mode, never in 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 = .{ .index = 1 }; const blue: pardes.Color = .{ .index = 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); // normal mode is a plain editing view: no ansi projection at all, so an // edit made here cannot change what tty mode renders. 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.expect(!std.meta.eql(red, norm.at(tx, body_y).style.fg)); try testing.expect(!std.meta.eql(blue, norm.at(tx + 1, body_y).style.fg)); } test "an edit overlay never changes tty-mode ansi colors" { 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; pane.mode = .tty; p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mAAA\x1b[0m\r\n\x1b[32mBBB\x1b[0m\r\n\x1b[34mCCC\x1b[0m" } }); 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 blue: pardes.Color = .{ .index = 4 }; p.shell_rows.stale = true; const before = try p.render(frame.allocator()); try testing.expectEqualStrings("C", before.at(tx, body_y + 2).grapheme()); try testing.expectEqual(blue, before.at(tx, body_y + 2).style.fg); // A lingering multi-line edit overlay must not move any shell row's colour. pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, "e\nd\ni\nt") }; p.shell_rows.stale = true; _ = frame.reset(.retain_capacity); const after = try p.render(frame.allocator()); try testing.expectEqualStrings("C", after.at(tx, body_y + 2).grapheme()); try testing.expectEqual(blue, after.at(tx, body_y + 2).style.fg); } 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; if (!pane.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(pane.vt.screens.active.pages.scrollbar().offset); const cursor_abs = pane.surfRow(goff + @as(i32, @intCast(pane.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]); } // ghostty calls this with a reply (cursor-position report, DA, ...) to send // back to the child as if it typed it. The handler's `terminal` is our Pane.vt // field; recover the Pane and stash the bytes — sync() drains them into write // effects (the callback has no path to the effect queue). pub fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void { const pane: *Pane = @alignCast(@fieldParentPtr("vt", handler.terminal)); const room = pane.reply.len - pane.reply_len; const n = @min(room, data.len); @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]); pane.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 .{}; }