diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 01:47:40 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | be404e0d4f91548af6ed600b2fd2027ad8a041c7 (patch) | |
| tree | fb3597f4f1fe9703a64726c22a3b9eb29609bcdf /src | |
| parent | 8a7112f08e0ce93a37b177e3aa83637e1691b451 (diff) | |
| download | pardes-be404e0d4f91548af6ed600b2fd2027ad8a041c7.tar.gz pardes-be404e0d4f91548af6ed600b2fd2027ad8a041c7.zip | |
focus history is a stack of locations, and the Jumplist is that same stack
One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is
now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that
array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's
directory order, Back/Forward, Last and Jumplist all walk the one list.
A pane id is reused, so a location that only remembered an id would retarget
after a respawn: panes now carry a monotonic serial and an entry whose slot
holds a different serial is dead. trackJump compacts those out and fixes the
pointer in the same pass.
The push rule lives in ONE place and says: a location is worth remembering
when you cannot see it any more — a different pane, or more than a bodyful of
rows away in the same one. So hjkl never grows the list and 100G, a search hit
and a goto-definition do.
Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them.
Ctrl-i IS Tab on a legacy host, where the binding simply never fires and
Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere.
Two goldens moved, both the SPC ? Help listing gaining four rows.
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 66 | ||||
| -rw-r--r-- | src/config.zig | 33 | ||||
| -rw-r--r-- | src/output_pane.zig | 71 | ||||
| -rw-r--r-- | src/pardes.zig | 173 |
4 files changed, 307 insertions, 36 deletions
diff --git a/src/builtins.zig b/src/builtins.zig index cf144d33..7e20a95b 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -351,13 +351,12 @@ pub const Right = struct { } }; -/// The file<->terminal hop. "Latest" is already recorded: focus_hist is the -/// MRU sync() rebuilds every update (most recent last), the same list a -/// closing pane hands focus back through — so this walks it instead of keeping -/// a second one. Which side is which: only a shell is a terminal; a file, an -/// image and an output buffer (+Search/+Help) are all DOCS you read, so -/// isTerminal is the whole test. Landing pushes this pane to the top of that -/// same history, which is why the hop back is the same key. +/// The file<->terminal hop. "Latest" is already recorded: the jump stack runs +/// oldest-first and a closing pane hands focus back through the same list — so +/// this walks it instead of keeping a second one. Which side is which: only a +/// shell is a terminal; a file, an image and an output buffer (+Search/+Help) +/// are all DOCS you read, so isTerminal is the whole test. Landing pushes this +/// pane onto that same history, which is why the hop back is the same key. pub const Toggleterm = struct { pub fn run(c: Ctx) void { const want_term = !c.pane.isTerminal(); @@ -369,10 +368,10 @@ pub const Toggleterm = struct { if (k != c.id and op.isTerminal() == want_term) t = k; } // ...but the most recently focused one wins - var i = c.p.nfocus; + var i = c.p.njumps; while (i > 0) { i -= 1; - const hid = c.p.focus_hist[i]; + const hid = c.p.jumps[i].pane; const hp = c.p.panes[hid] orelse continue; if (hid != c.id and hp.isTerminal() == want_term) { t = hid; @@ -386,6 +385,55 @@ pub const Toggleterm = struct { } }; +// ---- the jump group ---- + +// Where focus HAS BEEN, as three verbs and a list over the one stack pardes +// keeps (Pardes.jumps — see trackJump for what gets onto it). Builtins rather +// than bare key handlers for the same reason the four directions are: one +// implementation, reachable by chord, by `SPC j ...`, and by executing the +// word wherever it is written. + +/// Ctrl-o: one step back into the history. +pub const Back = struct { + pub fn run(c: Ctx) void { + c.p.jumpBy(-1); + } +}; + +/// Ctrl-i: one step forward again, up to wherever Back started. +pub const Forward = struct { + pub fn run(c: Ctx) void { + c.p.jumpBy(1); + } +}; + +/// vim's Ctrl-^: the pane you were in before this one, whichever it was — the +/// hop you press twice a minute and never want to count steps for. +/// +/// It does NOT move the stack cursor: it goes somewhere, so trackJump records +/// it like any other move, and that is exactly what makes it an involution — +/// after the hop, the pane you came from is the newest OTHER pane, so pressing +/// it again comes straight back. Back/Forward walk history; this one makes it. +pub const Last = struct { + pub fn run(c: Ctx) void { + var i = c.p.njumps; + while (i > 0) { + i -= 1; + const j = c.p.jumps[i]; + if (j.pane != c.id) return c.p.focusPaneLine(j.pane, j.line, j.col); + } + } +}; + +/// The same stack, as text you can read and click. Not a copy of it and not a +/// second list kept in step — the buffer is RENDERED from the stack when you +/// ask, the way +Search is rendered from a walk. +pub const Jumplist = struct { + pub fn run(c: Ctx) void { + output_pane.openJumps(c.p, c.id); + } +}; + // ---- the language group ---- // Reached as `SPC l <helix's letter>` — see leader_path for why the prefix diff --git a/src/config.zig b/src/config.zig index 394f9ca3..c4249270 100644 --- a/src/config.zig +++ b/src/config.zig @@ -123,6 +123,15 @@ pub const leader_path = std.EnumArray(Builtin, ?[]const u8).init(.{ .Up = "wk", .Right = "wl", .Toggleterm = "wt", + // the `j` JUMP group, its own letter rather than more of `w`: the window + // group moves focus by GEOMETRY (the pane left of this one), these move it + // by TIME (the pane I was in before). `o` and `i` are the letters of the + // chords that do the same thing, `jj` is the group's obvious verb, and + // `jl` is the list itself. + .Back = "jo", + .Forward = "ji", + .Last = "jj", + .Jumplist = "jl", // the two acme verbs: keys and buttons, no leader path — see above .Look = null, .Exec = null, @@ -180,6 +189,29 @@ pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }}; pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }}; +// ---- jumps ---- + +/// vim's Ctrl-o / Ctrl-i, walking the focus history back and forward. Global +/// in any mode, like the two Alt- ops above and for the same reason: getting +/// BACK has to work from inside a pane that owns its keys. +/// +/// CAREFUL, and this is why the table has a comment: **Ctrl-i is Tab**. On the +/// wire they are the same byte (0x09), so on a host that speaks only the +/// legacy encoding this binding never fires and 0x09 keeps meaning `exec_key` +/// below — which is the right way round, since Tab-executes is the older and +/// more used of the two. Where the host speaks the kitty keyboard protocol +/// (`CSI 105;5u`) the two are distinct keys and both work. Shift-Esc +/// (tty_toggle_alt) is already spelled on that same bet. +/// +/// Shaped like window_keys: the `cmd` column is what makes the chord +/// discoverable from the builtin as well as the other way round, and it is +/// what keeps ONE implementation — pressing the chord and executing the word +/// `Back` are the same call. +pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ + .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back }, + .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, +}; + /// `j` off the topbar drops back onto a tagline — the mirror of the `k` that /// got you there (window_keys' letter, answered by tagNormalKey). pub const topbar_down: []const Chord = &.{.{ .cp = 'j' }}; @@ -368,6 +400,7 @@ pub const symbol_marker = " WsSymbols /"; /// these changes only what you read in a tag. pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; +pub const jumps_buffer = "+Jumps"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; diff --git a/src/output_pane.zig b/src/output_pane.zig index 45eb0931..8e08285f 100644 --- a/src/output_pane.zig +++ b/src/output_pane.zig @@ -20,6 +20,7 @@ const Pardes = pardes.Pardes; const Pane = pardes.Pane; const File = pardes.File; const file_pane = @import("file_pane.zig"); +const modal = @import("modal.zig"); const builtins = @import("builtins.zig"); const Builtin = builtins.Builtin(); const config = @import("config.zig"); @@ -110,6 +111,9 @@ pub fn traits(o: Origin) Traits { .search => .{ .name = config.search_buffer, .steps = true }, .cmd => |b| switch (b) { .Help => .{ .name = config.help_buffer }, + // the focus history, one location per row: not a search, but the + // same kind of list, so n/N walk it and a row is a look target + .Jumplist => .{ .name = config.jumps_buffer, .steps = true }, // Find (file names) and Grep (file contents) both list locations; // no other builtin opens a buffer, and the day one does it lands // here rather than in a call site. @@ -188,6 +192,73 @@ pub fn open(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []const u return pane; } +/// The Jumplist builtin: the focus history (Pardes.jumps) written out as text, +/// one row per location, oldest first — the same `location text` shape every +/// results buffer here has, which is what buys n/N stepping and Look-on-a-row +/// for nothing: the leading word is an ordinary look target, and the ordinary +/// look path is what goes there. +/// +/// A RENDERING, never a second list. The rows are spelled from the stack at +/// the moment you ask and go stale the moment you jump, exactly like a search +/// result — which is also why this opens a fresh buffer per press instead of +/// refreshing one the way Help does: Help is a document, this is a snapshot. +/// +/// How a location is spelled is the rule runSearch already follows: a REAL +/// file names itself (its path is absolute, so the row resolves from any +/// pane's directory), and everything else — a terminal, an output buffer, an +/// image — has no file to point at and gets `@pN`. The trailing text is the +/// content line for anything holding text, else the pane's directory: enough +/// to recognise the place without opening it. +pub fn openJumps(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const arena = p.scratch.allocator(); + var out: std.ArrayList(u8) = .empty; + for (p.jumps[0..p.njumps]) |j| { + const jp = p.panes[j.pane] orelse continue; + var idbuf: [16]u8 = undefined; + const has_path = if (jp.file) |f| f.output == null else false; + const loc: []const u8 = if (has_path) + jp.file.?.path + else + std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch continue; + const what: []const u8 = if (jp.file) |f| + std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r") + else if (jp.image) |iv| + iv.path + else + jp.cwdSlice(); + // one line of a file can be the whole file: cut it, but never mid + // codepoint — a partial UTF-8 sequence reaches the renderer as a row + // and there is nothing sane for it to draw (grepText cuts the same way) + var cut = @min(what.len, 120); + while (cut > 0 and cut < what.len and what[cut] & 0xc0 == 0x80) cut -= 1; + // line 0 is a place with no spot in it (a shell whose cursor is still + // the program's): it is written WITHOUT the suffix, which is the same + // thing a bare path has always meant to a look. + const row = if (j.line == 0) + std.fmt.allocPrint(arena, "{s} {s}\n", .{ loc, what[0..cut] }) catch return + else + std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] }) catch return; + out.appendSlice(arena, row) catch return; + } + const content = p.gpa.dupe(u8, out.items) catch return; + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse { + p.gpa.free(content); + return; + }; + const np = open(p, free, dir, .{ .cmd = .Jumplist }, "", content) catch { + p.gpa.free(content); + return; + }; + p.placeDoc(id, free, np); + // focus stays with the pane that asked, exactly as it does after a search: + // n/N are read there, and they step the buffer they just armed. + p.active = id; + pane.search_pane = free; + pane.search_row = null; +} + /// The Help builtin: every builtin reachable from `prefix` (empty = all /// of them) with the keys that run it, written into an output buffer /// (acme's +Errors) — ordinary text, so the names in it are LIVE: middle- diff --git a/src/pardes.zig b/src/pardes.zig index 2365c33f..3bf32c82 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -44,9 +44,34 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config" // minimums — live in config.zig with everything else a user retargets. pub const MAX_PANES = 16; pub const MAX_COLS = 6; +/// how far back the jump stack remembers. Vim keeps 100; this is a session of +/// at most sixteen panes, so the depth that matters is "more visits than you +/// can hold in your head" and the oldest entry falls off the bottom. +pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; pub const BOX_H: u16 = 1; +/// A place the keyboard has been: a pane AND a spot in it, which is the whole +/// upgrade over the stack of bare pane ids this replaces — Ctrl-o can now +/// rewind WITHIN a pane, and a Jumplist row can name a line. +/// +/// It is SAFE against the pane it names dying: `serial` is the pane's own +/// identity, so an entry whose slot has since been handed to a different pane +/// reads as dead rather than silently retargeting itself at the newcomer, and +/// sync() drops it. What it does not do is outlive the pane — ponytail: a +/// location is a place in the SESSION, not on disk, so closing a file forgets +/// the entries pointing into it. To make Ctrl-o RE-OPEN a closed file, this +/// grows a path field and jumpBy looks it when the pane is gone. +pub const Loc = struct { + pane: u16, + serial: u32, + /// 1-based, both — this is the `path:LINE:COL` a look word spells, and + /// focusPaneLine takes exactly these. 0 = no spot, just the pane (see + /// trackJump: a shell whose cursor is still the program's). + line: u32, + col: u32, +}; + const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; @@ -654,6 +679,11 @@ pub const Image = struct { pub const Pane = struct { vt: ghostty_vt.Terminal, stream: ghostty_vt.TerminalStream, + /// WHICH pane this is, for anything that outlives the pane: slots are + /// REUSED (freeSlot hands back the lowest free one), so a remembered id + /// alone can silently come to mean an unrelated pane. Handed out by + /// Pardes.next_serial and never reused. The one reader is the jump stack. + serial: u32 = 0, mode: Mode = .normal, vweight: f32 = 1, cols: u16, @@ -899,11 +929,21 @@ pub const Pardes = struct { col_w: [MAX_COLS]u16 = undefined, active: usize = 0, - /// keyboard focus history, least-recent first, `active` last: rebuilt each - /// sync (dead slots pruned, ids are reused). A closing pane hands focus - /// back to the last live one instead of whatever slot comes first. - focus_hist: [MAX_PANES]usize = undefined, - nfocus: usize = 0, + /// THE focus history: where the keyboard has BEEN, oldest first, and `jcur` + /// is the entry it is at NOW (so `jumps[jcur]` is always the live spot and + /// the entries past it are the ones Ctrl-i walks forward into). Maintained + /// in exactly one place — sync() — and read by everything that asks "where + /// was I": Back/Forward, Last, the Jumplist buffer, prevFocus when a pane + /// closes, Toggleterm, and the directory order a look resolves in. + /// + /// One list, not two. A jumplist kept beside a focus history is two things + /// to keep agreeing, and they would disagree the first time one of them + /// forgot a pane the other still names. + jumps: [MAX_JUMPS]Loc = undefined, + njumps: usize = 0, + jcur: usize = 0, + /// hands out Pane.serial; monotonic, never reused + next_serial: u32 = 0, theme_idx: usize = 0, colors_on: bool = true, show_debug: bool = false, @@ -1111,6 +1151,8 @@ pub const Pardes = struct { // crossterm apps (nushell, helix, fzf) block on the reply forever. pane.stream.handler.effects.write_pty = term_pane.ptyReport; pane.stream.handler.effects.device_attributes = term_pane.ptyDeviceAttrs; + p.next_serial += 1; + pane.serial = p.next_serial; p.panes[id] = pane; p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } }); return pane; @@ -1128,6 +1170,8 @@ pub const Pardes = struct { .rows = p.screen_h, }; pane.stream = pane.vt.vtStream(); + p.next_serial += 1; + pane.serial = p.next_serial; p.panes[id] = pane; return pane; } @@ -1690,6 +1734,14 @@ pub const Pardes = struct { p.active = free; return; } + // the jump chords, global for the same reason: Ctrl-o has to get you + // out of wherever you are, including a pane in raw tty mode. Above the + // acme chords below, which is what makes Ctrl-i reachable at all — on + // a kitty-protocol host it arrives as its own key, and where it does + // not it IS Tab (0x09) and falls through to Exec, see config.jump_keys. + for (config.jump_keys) |jk| { + if (hit(key, &.{jk.chord})) return p.runBuiltin(jk.cmd, p.active, "", null); + } if (hit(key, config.pane_to_new_column)) { const f = p.layoutFindTerm(p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { @@ -4888,7 +4940,7 @@ pub const Pardes = struct { /// there (`col` likewise, 0 = line start): files recenter the view on it, /// terminals ride their scrollback to it. Both look targets that name a /// live pane land here — a path a pane already holds, and `@pN:LINE:COL`. - fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void { + pub fn focusPaneLine(p: *Pardes, id: usize, line: usize, col: usize) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; p.active = id; @@ -4956,7 +5008,7 @@ pub const Pardes = struct { // The word is resolved against the pane DIRECTORIES in access // order: the pane the click came from FIRST — its answer is the // one taken, so nothing that resolves today moves — then every - // other live pane, most recently focused first (focus_hist runs + // other live pane, most recently focused first (the jump stack runs // least-recent -> active, so it is that array backwards; the // clicked pane is not always `active`, a right click does not // focus). Only when ALL of them fail does the word fall through to @@ -4970,8 +5022,8 @@ pub const Pardes = struct { var seen: [MAX_PANES][]const u8 = undefined; var nseen: usize = 0; var n: usize = 0; - cand: while (n <= p.nfocus) : (n += 1) { - const k = if (n == 0) id else p.focus_hist[p.nfocus - n]; + cand: while (n <= p.njumps) : (n += 1) { + const k: usize = if (n == 0) id else p.jumps[p.njumps - n].pane; if (n > 0 and k == id) continue; // tried first, skip the rerun const op = p.panes[k] orelse continue; const dir = if (op.file) |f| (std.fs.path.dirname(f.path) orelse "/") else op.cwdSlice(); @@ -5409,11 +5461,13 @@ pub const Pardes = struct { /// where focus falls when the active pane closes: the most recently /// focused pane still alive (else any live one). Null = nothing left. + /// Walks the jump stack newest-first, so it answers exactly what it always + /// did — a pane's newest entry sits where the old MRU put the pane. pub fn prevFocus(p: *Pardes, closing: usize) ?usize { - var i = p.nfocus; + var i = p.njumps; while (i > 0) { i -= 1; - const id = p.focus_hist[i]; + const id = p.jumps[i].pane; if (id != closing and p.panes[id] != null) return id; } for (p.panes, 0..) |slot, k| { @@ -5422,26 +5476,91 @@ pub const Pardes = struct { return null; } + /// THE PUSH RULE, and the only place it is written down. + /// + /// A location is worth remembering when you cannot see it any more: focus + /// ended this update in a DIFFERENT pane, or more than a bodyful of rows + /// away in the same one. Anything closer is the cursor strolling, and the + /// current entry just follows it — so h/j/k/w/b never grow the list, while + /// a goto-line, a search hit, a goto-definition and every focus change do. + /// (Vim's rule is a hand-kept list of "jump commands"; this one asks the + /// question those commands are a proxy for, and needs no list.) + /// + /// It is read HERE, once per update, and nowhere else: what a call site + /// does transiently is invisible, which is what keeps n/N over a results + /// buffer — which focuses each hit and comes straight back — from pushing + /// two entries per keystroke. That transparency is the whole reason the + /// rule lives in sync() rather than at the sites that move focus, which is + /// where the heuristics used to be scattered. + fn trackJump(p: *Pardes) void { + // dead entries first, in one compacting pass. A slot is reused, so the + // test is the SERIAL: an entry whose pane has been replaced names a + // pane that no longer exists, not the newcomer sitting in its slot. + var w: usize = 0; + var cur: usize = 0; + for (p.jumps[0..p.njumps], 0..) |j, i| { + if (i == p.jcur) cur = w; // survives -> lands at w; dies -> the next one does + const pane = p.panes[j.pane] orelse continue; + if (pane.serial != j.serial) continue; + p.jumps[w] = j; + w += 1; + } + p.njumps = w; + p.jcur = @min(cur, w -| 1); + + const pane = p.panes[p.active] orelse return; + // An UNPINNED cursor belongs to the program on the other end of the + // pty, not to you, so such a pane is remembered as a place and not as + // a spot: line 0 is the "no line" focusPaneLine already understands, + // and going back there focuses the shell without dragging its view up + // to scrollback row 0. It also has no line to be FAR from, which is + // what keeps the first keypress in a shell (which pins the cursor + // wherever the prompt is) from reading as a jump. + const now: Loc = .{ + .pane = @intCast(p.active), + .serial = pane.serial, + .line = if (pane.cur_pinned) @intCast(@max(0, pane.cur_row) + 1) else 0, + .col = if (pane.cur_pinned) @intCast(@max(0, pane.cur_col) + 1) else 0, + }; + if (p.njumps > 0) { + const at = &p.jumps[p.jcur]; + const body = @max(1, p.rects[p.active].h -| BOX_H); + const far = at.line != 0 and now.line != 0 and + @max(now.line, at.line) - @min(now.line, at.line) > body; + if (at.pane == now.pane and !far) { + at.* = now; // same visit; the entry IS where you are + return; + } + } + // a new jump made from the middle of the list drops everything ahead of + // it, the way vim's does: the future you did not take is not history. + if (p.njumps > 0) p.njumps = p.jcur + 1; + if (p.njumps == MAX_JUMPS) { + std.mem.copyForwards(Loc, p.jumps[0 .. MAX_JUMPS - 1], p.jumps[1..]); + p.njumps -= 1; + } + p.jumps[p.njumps] = now; + p.jcur = p.njumps; + p.njumps += 1; + } + + /// Ctrl-o / Ctrl-i (the Back and Forward builtins): move the CURSOR into + /// the stack and go to what it names. Nothing is pushed and nothing is + /// dropped — walking history is not making it — and trackJump agrees, + /// because after the move the live spot IS `jumps[jcur]` again. + pub fn jumpBy(p: *Pardes, delta: i32) void { + const next = @as(i64, @intCast(p.jcur)) + delta; + if (p.njumps == 0 or next < 0 or next >= p.njumps) return; + p.jcur = @intCast(next); + const j = p.jumps[p.jcur]; + p.focusPaneLine(j.pane, j.line, j.col); + } + /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { p.computeGeom(); - // focus history, rebuilt in one pass: drop dead slots and the current - // active, then push active back on top. Every focus change (mouse, - // look, Ctrl-w, Alt-n) lands here without touching its call site. - { - var w: usize = 0; - for (p.focus_hist[0..p.nfocus]) |id| { - if (p.panes[id] == null or id == p.active) continue; - p.focus_hist[w] = id; - w += 1; - } - p.nfocus = w; - if (p.panes[p.active] != null) { - p.focus_hist[p.nfocus] = p.active; - p.nfocus += 1; - } - } + p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; if (pane.reply_len > 0) { |
