summaryrefslogtreecommitdiff
path: root/src/term_pane.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 13:01:13 -0300
committerGabriel Schneider <[email protected]>2026-08-25 17:13:54 -0300
commit939e3a7288d6782139cac36f091ccd1d41cdfc0a (patch)
tree6b7b86c2ba4ade9f34c8f174354425a5d31a150a /src/term_pane.zig
parente5f9e172330bc4500995ddd5954ed94f94e07af6 (diff)
downloadpardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.tar.gz
pardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.zip
A fourth platform: pardes as ESP32-P4 firmware, bytes in and bytes out
`-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT exporting a seven-function C ABI, not an executable. The board's toolchain (../05-zig-p4) owns `_start`, the linker script and the UART driver and links this in. The seam is bytes rather than types, so neither side can accidentally depend on the other's internals, and a signature that drifts fails at link time. The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the terminal emulator on the host answers the capability handshake and the firmware sees a real terminal. Measured going out over the wire on attach: alt screen, in-band resize, cursor report, kitty keyboard, kitty graphics, DA1. THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and `Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from the TARGET, because they are a property of running with no OS under you rather than a product option, and because wasm is freestanding too and is exactly what must be excluded: in a browser an address is an offset into the linear memory this editor's own heap lives in. Every access goes through `*allowzero volatile`: a peripheral register is not memory, and address 0 is an ordinary unmapped address on this bus. One 4 KiB cap per command, set by the console rather than the memory - an unbounded dump would wedge the only console the board has for eleven hours. Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal: Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the RNG register, so the volatile loads are not folded Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter Peek 0x50110001 `peek: MisalignedAddress` on the message row That last line is the one that matters. A misaligned 32-bit access traps, and a trap in firmware is a watchdog reset that takes the session with it, so the check that turns it into a message is the reason the file is hand-written rather than a generic reader. BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a shell, and on this platform that is not a preference but an impossibility: nothing to fork, no pty to give a terminal pane. Booting one anyway produced precisely what that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every keystroke vanishing into the Fallback's silent pty. An output buffer is also what the platform's own words want, since Peek, Poke and Hexdump each fill one. Sized for the board rather than for a desktop: * `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero, so each arena spills immediately to the 384 KiB heap the firmware hands over, and no megabyte-shaped static reservation lands in `.bss`. * `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of rodata against a 1.5 MiB flash partition; the firmware's filesystem is the serial host's, through the Host vtable. * The grid is clamped and the clamp is measured, not guessed: every cell is paid for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells), so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`. * `Vaxis.resize` deinits both screens before allocating replacements, so a failed resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry on failure instead of leaving a half-applied one. Also here: `output_pane_integration_test.zig` had an exhaustive switch over `Platform` that adding `.p4` left unhandled, which broke `zig build unit-test` outright - the native test binary is the one consumer no platform build compiles. 346 tests pass again.
Diffstat (limited to 'src/term_pane.zig')
-rw-r--r--src/term_pane.zig263
1 files changed, 236 insertions, 27 deletions
diff --git a/src/term_pane.zig b/src/term_pane.zig
index 7662292e..cda45394 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -9,8 +9,17 @@
//! 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 ghostty_vt = @import("ghostty-vt");
const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
@@ -20,7 +29,29 @@ const modal = @import("modal.zig");
const config = @import("config.zig");
const dump = @import("dump.zig");
-pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+/// `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;
@@ -37,11 +68,16 @@ const FilterPaletteKey = struct {
/// 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`).
-pub const FilterPalette = struct {
+///
+/// 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: *FilterPalette, theme: *const pardes.Theme) *const GColor.Palette {
+ 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;
@@ -126,6 +162,10 @@ pub const PendingCommand = struct {
/// 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;
}
@@ -200,17 +240,107 @@ pub fn deinitPendingCommand(pane: *Pane) void {
/// has no host IO and must not instantiate std.Io.Threaded's POSIX backend
/// merely to construct a replay-only terminal.
pub fn terminalIo() std.Io {
- return if (comptime pardes.platform == .web)
+ 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,
@@ -233,10 +363,39 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
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
@@ -244,9 +403,9 @@ pub fn restore(p: *Pardes, src: dump.Pane) !*Pane {
const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows));
if (bytes.len > 0) {
ingest(pane, bytes);
- pane.vt.screens.active.scroll(.active);
+ followOutput(pane);
if (src.scroll > 0)
- pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ scrollGrid(pane, -@as(i32, @intCast(src.scroll)));
}
return pane;
}
@@ -288,10 +447,12 @@ fn replayBytes(pane: *const Pane, allocator: std.mem.Allocator) ![]const u8 {
/// 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) pane.vt.screens.active.scroll(.active);
+ if (body_mode == .tty) followOutput(pane);
}
/// True only after OSC 133 B ended the prompt and handed the cursor to shell
@@ -299,6 +460,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
/// 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;
}
@@ -435,8 +597,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void {
/// the arrow-key movement the child understands.
pub fn enterTty(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- const screen = pane.vt.screens.active;
- if (pane.cur_pinned and pane.vt.cursorIsAtPrompt()) handoff: {
+ // 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;
@@ -458,7 +623,7 @@ pub fn enterTty(p: *Pardes, id: usize) void {
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;
@@ -567,6 +732,11 @@ const Rows = struct {
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) {
@@ -643,6 +813,12 @@ fn promptRow(pin: ghostty_vt.Pin, raw: []const u8) []const u8 {
/// 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) {
@@ -792,6 +968,27 @@ pub fn dumpPane(
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));
@@ -885,22 +1082,28 @@ fn buildRows(alloc: std.mem.Allocator, p: *Pardes, pane: *Pane) !Rows {
/// 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 {
- 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
+ // 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
- else
- ln;
- n += 1;
- }
- std.debug.assert(n == vp.len);
+ 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);
@@ -912,7 +1115,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
/// 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 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ 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
@@ -959,6 +1162,9 @@ fn fillBody(dst: ?[]u8, pane: *Pane, viewport: []const []const u8) usize {
/// 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;
@@ -1415,6 +1621,9 @@ pub fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) pardes
/// 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;