summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
Commit message (Collapse)AuthorAge
* Kill asks the host to signal at once and says when a shell has no job to signalGabriel Schneider35 hours
| | | | | | | | | | | Kill queued a signal effect for later, so the job it saw running by its marks could have ended, and another started, before the signal went; and with job control off the job shares the shell's process group, so the host skipped it and Kill reported nothing. Kill now calls the host's kill_job while it holds the turn, and when the only job is the shell's own group says 'Kill: no job to signal', which also fails a write of Kill to ctl. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* A closed terminal's shell is reaped on tty and macOS, killed if it ignores ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | the hangup tty and macOS hung the pty up and called waitpid once without waiting, so a shell still exiting, or one that ignores SIGHUP, stayed a zombie or ran on with nobody reading it; tty also never reaped a shell that exited by itself, and its spawn into an occupied slot closed the old pty without ending the shell. host_io's retireShell says hangup, waits 100 ms on a thread (macOS has no host timer to poll from), then kills and reaps. The gui's own retired list, when full, left the slot holding the old shell and refused the next spawn into that pane; it now hands that shell to retireShell instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* A terminal pane that closes takes its shell and pty with it, in every front endGabriel Schneider35 hours
| | | | | | | | | | | | | The detached server closed a pane's pty only when the shell was respawned, hit EOF or the session shut down, so rmdir, Del or Delcol on a terminal left its shell running with nobody to read it; the tty front end and macOS did the same. Retiring a terminal pane now emits a close_pty effect, which each host that runs shells answers by hanging the pty up and ending the shell (the detached server's and the GUI's existing retire-and-reap path, and a close plus SIGHUP in the tty and macOS shells). Co-Authored-By: Claude Opus 5.5 <[email protected]>
* File the tag code into tagline.zig and draw tags beside bodiesGabriel Schneider35 hours
| | | | | | | | | | | With tags reduced to Texts, what is left of them is the computed prefix, the default and saved tails, entering and leaving a tag and the headers: that goes to tagline.zig, as acme keeps the tag half of a window in wind.c. Tag and header drawing moves next to body drawing in body_layer.zig, and the tag hit helpers go to tag_layer.zig with the Hit they read, where sameCell now also tells the lines of a taller tag apart. The docs describe the tag as a Text. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Let the column and workspace tags take a row for each line they holdGabriel Schneider35 hours
| | | | | | | | A header is a Text like a pane's tag, so a newline typed or pasted into it should show rather than hide the lines after the first; the panes below move down to make room, as they do for a taller pane tag. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Let a pane's tag take a row for each line it holdsGabriel Schneider35 hours
| | | | | | | | | | | A tag that is a text can hold a newline, and the tagline, the body under it and the pointer all assumed one row. The pane's tag now takes a row per line (up to eight, and never the body's last row); the body starts below it, the rows a pointer counts start the body after the tag's lines, a click on any line puts the tag's cursor there, and a pixel shell gets a tag layer per line. A tag of one line draws exactly as before. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Make the column and workspace tags Texts with the pane tag's keysGabriel Schneider35 hours
| | | | | | | | | | | | | The headers had their own one-line editor with its own keys: Enter always executed, h/l and J/K moved between headers and panes, Ctrl-c/x/v cut and pasted, and TagLine refused a newline that 9P accepted. They are now Texts like a pane's tag, with no prefix and a default until edited, so normal and insert mode, undo and the look and execute keys are the body's. Moving between them is the window keys' job: Up from a pane with nothing above it reaches its column's tag and then the workspace's, Down comes back, and Left and Right walk the column tags. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Give a pane's body its own Text holding the cursor, selections, mode and undoGabriel Schneider35 hours
| | | | | | | | | | | acme keeps what edits a text in its Text (dat.h:171-190) and the window holds a body and a tag of that type. The cursor, the selections, the modal state and the edit-buffer undo move off Pane into Text.zig, Pane holds them as its body, and the edit and normal-mode operations take the Text they edit. Nothing changes in behaviour; this is the step that lets the tag become a second Text. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Give the pipe its own fields in PipeGabriel Schneider35 hours
| | | | | | | | | | | | | | Not a pure move: state moves. Pardes's `pipe_seq` and `pipe_wait` become Pipe.zig's own fields `seq` and `wait`, and Pardes embeds one as `pipe: Pipe = .{}`. pipeRequest reads only the request in flight, so it now takes `pipe: *const Pipe` and the four shells call `core.pipe.pipeRequest(id)` (their Pipe imports go away again); the other pipe functions still need the panes and keep `p: *Pardes`, writing `p.pipe.seq/wait`. Field reads in dump.zig, the shells' tests and two test files follow (`core.pipe_wait` becomes `core.pipe.wait`). No behaviour change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Split the pipe's editor side out of selection_pipe.zig into Pipe.zigGabriel Schneider35 hours
| | | | | | | | | | | | | | | | Pure move, no behaviour change: the editor half of `|` that the earlier change put at the end of selection_pipe.zig (PendingPipe, pipeMarker, submitPipe, pipeRequest, pipeFailed, pipeCut, pipeOutput, pipeResponse, and the eight pipe tests with nextPipeEffect) now lives in its own Pipe.zig, so the pipe's editor state can become Pipe's own fields next. selection_pipe.zig goes back to exactly what it was before this series: the native runner and the boundary values the shells hand to their workers. The moved code names the runner's types as `selection_pipe.X` again, as it did in pardes.zig; callers change from `selection_pipe.submitPipe(p, ..)` to `Pipe.submitPipe(p, ..)` (pardes.zig and the four shells). Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Move saving and loading a whole editor out of pardes.zig into dump.zigGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | Pure move, no behaviour change (acme keeps dump and load in rows.c): dumpState, restore, initFromDump and initDump go verbatim to the end of dump.zig, after the dump format they read and write. Inside dump.zig the moved code's `dump.` prefix drops, so `Pane` there is the dump record; the one editor pane it names is spelled `pardes.panes.Pane`, and its other `panes.X` references become `pardes.panes.X` because dump.zig's own tests use `panes` as a local name. The methods become free functions taking `p: *Pardes`: `p.dumpState()` becomes `dump.dumpState(p)`, `core.restore(bytes)` becomes `dump.restore(core, bytes)` and `Pardes.initFromDump(..)` becomes `dump.initFromDump(..)`, in pardes.zig, the shells, layout.zig, Terminal.zig, builtins.zig and the tests (38 receiver rewrites plus the initFromDump calls). The tag-tail restore helpers stay with the tag code. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Move the message row out of pardes.zig into Messages.zigGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | Pure move, no behaviour change: the Message stamp helpers, LoggedMessage, setStatus, setMessage, showMessage, dismissMessage, dismissLine, advanceMessages, advanceLine, messagesAnimating, MessageMotion, messageFrames, messageMotion, noticeText, noticeLife, blendRgb, logMessage, messageLog, reportError, the notice painters (leaderText, noticeCols, Printed, printRight), collectNotices, and the six message tests go verbatim to Messages.zig. The methods become free functions taking `p: *Pardes`. setStatus, setMessage and reportError are called from ~170 places as `p.setMessage(..)`, so Pardes keeps three declaration aliases (`pub const setMessage = Messages.setMessage;`) and those call sites stay as they are; every other call changes from `p.x(..)` to `Messages.x(p, ..)` (46 of them). The five shells' `pardes.Pardes.Message` become `pardes.Messages.Message`. The message ring's fields stay on Pardes for now; moving them into Messages is a separate change. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Move the pipe's editor side out of pardes.zig into selection_pipe.zigGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | Pure move, no behaviour change: submitPipe, pipeRequest, pipeFailed, pipeCut, pipeOutput and pipeResponse, the PendingPipe they share, pipeMarker, and the eight pipe tests with their nextPipeEffect helper go verbatim to the end of selection_pipe.zig, so the whole of `|` (runner, boundary values, prompt, request and atomic edit) is one file. Inside that file the `selection_pipe.` prefix drops; the file doc now says it holds both halves. The methods become free functions taking `p: *Pardes`: the four shells' `core.pipeRequest(id)` become `selection_pipe.pipeRequest(core, id)`, and pardes.zig's two calls change the same way. pushUndo becomes pub because the pipe's edit calls it; PendingPipe.deinit becomes pub for Pardes.deinit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Upload only the atlas rows new glyphs were drawn into, not the whole atlasGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Any new glyph re-uploaded the whole 2048x2048 atlas: 4 MB copied into the transfer buffer and then to the texture, about half a millisecond of CPU on the frame, and the GPU's copy on top. Glyphs fill the atlas pen-style, a band of rows at a time, so a frame's new glyphs sit in a band a cell or two tall. The atlas now keeps the rows drawn into since the last upload (cachedGlyph widens them by each raster's rows: plain glyphs, ligature strips, tagline and grip glyphs alike), and uploadAtlas copies and uploads just those rows, at the same offset in the transfer buffer as in the stage; the texture keeps the rest. A reset (a font, size or tagline change) marks every row, as the texture's first upload does, so those still send the whole atlas. A test replays uploads over glyphs of every kind and a reset, and checks that no row outside the dirty rows ever differs from what the texture holds. Hidden captures of 27 layouts, including new glyphs typed and printed, font and size switches, and Ligatures off and on, are byte-identical to before. Over 10 interleaved rounds (160x50 cells), a frame that draws new glyphs spends 12-50 us uploading instead of about 0.5 ms: a terminal printing twelve new glyphs a line goes from 1.04 to 0.55 ms of renderFrame CPU on such frames (-34% over the scenario's frames, 9 of 10 rounds faster, -20% to the GPU fence), a typed glyph's frame from 0.89 to 0.38 ms, and opening a file of 600 new glyphs from 9.1 to 8.7 ms. The first frame and a font size change still upload everything and cost what they did; other frames are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Find the grid cells under layers once a frame, not by scanning every layer ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | for each cell renderFrame skips the grid cells a body or tag layer paints itself, and it asked which those are cell by cell: for every cell, twice a frame (counting instances, then emitting them), bodyLayerAt and tagLayerIn walked all 16 body layers and all 119 tag layers. In a ReleaseFast profile of a full screen of src/pardes.zig those walks were about a third of the gui's CPU samples. coverLayers now fills each live layer's rectangle into a per-cell map once a frame, and the loops read one byte. The loops only ever asked whether some layer covers a cell, never which, so overlap order does not enter into it; the map marks exactly the cells the walks found: a tag layer only its viewport's first row, and a viewport past the grid only the grid's part of it. The loops over the layers a panel transition leaves get a second map, built only while one runs. A test holds the map to the old walk over a core's layouts (columns, stacked and collapsed panes, column tags, Tagbottom, message and leader chips) and 3000 random layer sets, some past the grid's edges or at the top of u16. Hidden captures of 27 layouts (message and prompt chips, a column grip rail, a PDF, an image, font and tagline changes), with and without leftover edge pixels, are byte-identical to before. Over 10 interleaved rounds (160x50 cells), renderFrame's CPU a frame falls from about 2.0 ms to 0.32-0.37 ms idle, scrolling, typing, under terminal spew and over a PDF (-82 to -84%, every round), and cell emission from about 1.07 ms to 0.27 ms. The first frame's CPU goes from 6.2 to 4.6 ms. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Turn ligatures off with a Ligatures setting, in the shell that shapes textGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | The SDL shell shapes words with HarfBuzz, so a font's `->` and `!=` draw as ligatures, and there was no way to have the plain glyphs back short of changing font. `Ligatures` is a toggle, on by default; off, no cell goes to the shaper and every cell draws its own glyph, exactly as a font without ligatures does. It exists only where it means something. A new `ligatures` capability, true for the gui shell alone, gates it like Font and WindowOpacity are gated: it is not a builtin elsewhere, has no leader path, and Config does not list it, rather than print a row the TTY could never change. macOS draws CoreText ligatures of its own, but nothing there reads the setting, so it stays off there. The table check that kept every toggle unconditional now lets the ligatures toggle, and only it, carry a capability, and requires that it carry `ligatures`; every other setting's rule is as it was. The gui keeps the setting beside its text caches, which were resolved under it: when the core's value changes, the per-codepoint cells (which record whether a cell is shaped) and the shaped words are dropped, and the frame the toggle asked for draws every cell again. The atlas keeps its glyphs: plain ones draw either way, and a ligature's strip is reused when it comes back. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Track Maple Mono, so the ligature tests always runGabriel Schneider35 hours
| | | | | | | | | | | The GUI's ligature tests shape `->` and `==` with Maple Mono NF, the only ligature font in assets/, but the font was ignored, so a checkout without a private copy skipped the tests or ran them without their Maple half, and passed. The font is SIL OFL 1.1, whose licence is already tracked beside it, so it is committed and the tests now fail when it is missing instead of quietly passing. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Allocate SDL's memory from the gui's Zig allocator, and give it all back on ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | the way out SDL allocated with libc malloc. runNative now installs the shared C heap over the gui's gpa with SDL_SetMemoryFunctions before SDL's first call: SDL frees with whichever functions are current, so none of its memory may predate them. SDL allocates from its own threads too, and the gpa is thread-safe in every build (a DebugAllocator in Debug, libc's malloc in release). That put SDL in a Debug build's leak report, and the report filled up: 1769 blocks at exit, because runNative never released its GPU objects, its window or SDL itself. The process was about to exit, but a report that long hides any leak that matters. runNative now releases every pipeline, texture, sampler and buffer the Gui holds, closes the gamepad, destroys the GPU device and the window, and calls SDL_Quit, all through defers. Eight 32-byte blocks are left, and they are SDL's: 3.4.4's VULKAN_INTERNAL_DestroyCommandPool never frees two arrays per command buffer (buffersUsedInPendingTransfers and texturesUsedInPendingTransfers), which SDL's main branch now frees. They go away with the next SDL; a comment at the SDL_Quit says so. The frame-cost harness and a software-present run (PARDES_SOFT_PRESENT) both run and exit cleanly in Debug. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Hand every C library's allocator hook the same C heapGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C's malloc, calloc, realloc and free over a Zig allocator were written three times: syntax.zig's for tree-sitter, pdf.zig's for MuPDF, and gui.zig's for FreeType and HarfBuzz. They are now one, src/c_heap.zig's Heap. Each hook names the allocator variable its heap reads; gui.zig exports its heap under the ui_malloc names font.c and HarfBuzz call. It is a module of its own because pdf.zig is the MuPDF module, and a file cannot belong to that module and the core's at once. A block starts with a 16-byte header holding its size and the allocator that made it, so it goes back where it came from even after the heap is repointed, as the fonts' copy did. The fonts' copy carried the 16-byte Allocator itself in a 32-byte header; here the header holds the allocator's index in a table of every allocator a heap has used (entries are published once and never change, and a heap remembers where its current one was last found). The difference is measurable: with the 32-byte header, pardes-pdf-bench's filtered page render at 96 dpi took 1.1% longer than before this change, slower in 15 of 16 pinned rounds in two separate runs, and highlighting all of src/pardes.zig (305k tree-sitter blocks) up to 0.6% longer, against an A/A pair within 0.7%. With the 16-byte header both are back within the A/A pair's noise. Two other things change. For tree-sitter and MuPDF, a block made before the allocator was repointed now goes back to the one that made it rather than through the current one. For the fonts, realloc(block, 0) now frees and returns null, as glibc's does and as the other two copies already did; neither FreeType nor HarfBuzz asks for it. The heap's test replaces the tests of the old copies. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Ease a message into its row fast first, its colour ahead of it, and out slow ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | first A message fell in on t*t, an ease-in: it hung near its start for the first frames and was still moving at its fastest when it hit the row, a sixth of a row in its last frame. It now decelerates into place with an ease-out cubic, slide = -(1-t)^3: a quarter of the way in the first frame, and the last frames only settle it. There is no overshoot. At a row's height and the dozen frames of the 180 ms fall, a follow-through is a pixel or two, which reads as a jitter rather than a bounce. MessageFall keeps its 180 ms: the ease-out puts the chip where it is going within about 100 ms. The colour leads the motion: alpha = 1 - (1 - min(1, 2t))^2, whole by half way, so what lands is already legible. The SDL shell used to show the chip solid while it slid and fade it only on the way out; it now fades it up with the same alpha, and the rule under a chip carries the chip's fade (a new TagLayer.fade) instead of standing solid while the chip comes or goes. The dissolve was an ease-out, (1-t)^2: it lost a tenth of its colour in the first frame and spent its last frames nearly invisible. An exit eases in instead, alpha = 1 - t^2 over the same 300 ms: it lets go gently and leaves faster and faster. t is now counted to the last frame, so that frame shows it gone rather than it blinking out from 0.3%. 1 - t^3 was the other candidate; in captures it held the chip nearly untouched for half the dissolve after the linger had already held it, then dropped a sixth of its colour a frame, which reads as a pop. It stays in place: the lines stacked around it do not move, and a drift would read as it leaving its row. The test pins the shapes: a fast first step into the row, never past it, solid by half way; a dissolve whose first drop is smaller than its last and which ends at exactly nothing. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Ask from the keyboard which neighbour a closed pane's rows go toGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | Del takes a side: `Del k` gives the closed pane's rows to the nearest expanded pane above it, `Del j` to the one below, each falling back to the other side when it has none. DelAbove and DelBelow are those two lines, with no path of their own under SPC. A bare Del started from a key -- SPC d, Enter on the tag word, a row run from an output buffer -- on a pane with expanded panes both above and below asks instead of guessing. The question is a prompt like Save's or a search's (Pane.Prompt.del_side), so it is painted on the pane's notice band by the same path, and the next key answers it before any mode sees it: k or Up, j or Down, anything else keeps the pane, as does a click. Only a key press sets Pardes.can_ask, so a click, a 9P ctl or event write, a startup line, a restore and a shell exiting all close the pane at once, the rows going where layout.absorbVWeight has always sent them. A collapsed pane is not asked about (it has only a tag row to give), and collapsed neighbours are passed over (layout.expandedNeighbor, which Collapse now uses too). removePane and absorbVWeight take the recipient; every other caller passes null. Three scripts that closed a middle pane with SPC d answer k, which is where the rows went before, and their goldens are unchanged. delask.snap covers the question, Esc, j, a clicked DelBelow and a clicked Del. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Rule off notice chips from the body the way a tagline isGabriel Schneider35 hours
| | | | | | | | | | | | | | | A notice chip -- Msg output, an older message, the leader chord, a prompt -- is a piece of tagline hung over the body's top rows, but in the SDL shell it ended in a bare edge while the tagline above it ends in a rule. It now has the tagline's rule along its bottom: the chrome border colour, one pixel, running on to the window edge when the chip does. A chip still falling into place brings its rule with it, cut at its row's top like the chip. The macOS shell already rules every non-workspace tag layer, notices included; a terminal draws no rule under its tagline, so its chips get none either. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Light a held column grip, and rail its landing beside the rule where the GUI ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | shows it The column grip worked in the GUI but showed nothing anyone could see. Driving the real SDL window with pixel mouse events, press, drag and release all reach the core and reorder or resize the column; the feedback is what failed. The grip kept its muted color while held, and a reorder's insertion rail was drawn in the gutter cell at the destination edge. The GUI paints that cell as the column's scroll rail, and at the window's left edge, the common case of moving the right column first, the dashes were lost in it. From the leftmost column, a drag short of a neighbour's middle shows no rail, rightly, since nothing would move, but nothing said the grip was even held. A held grip now lights in column_box, the accent its muted color is mixed from, and the rail takes that color. The rail runs in the seam cell left of where the column's rule will land, where a border drag's rail runs, for a reorder and a left-edge move alike; at the window's left edge, which has no seam, it runs just past the scroll rail, leaving column 0's grip and pane boxes whole. A carried column's pointer aims at a place, not a word, so the column tags no longer light the word under it, and a release snaps the layout as a border drag's does rather than animating it. The preview and the release both ask layout.columnDrop where the column goes, so the rail stands where the release puts the edge, and the drag no longer carries its unused column index. The GUI test feed takes ESC]777;mouse;<down|up|motion>;<button>;<x>;<y> in window pixels and dispatches it as an SDL event. test/column_grip.py uses it to drive the grip through the same pixel-to-cell and tag hit path as a hand on the mouse, and checks the grip, the rail and the result against GPU captures and the 9P grid. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Allocate FreeType's and HarfBuzz's memory from the gui's Zig allocatorGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | FreeType and HarfBuzz allocated with libc malloc, out of sight of the Zig allocator the rest of the gui uses, so its Debug leak report and a test's std.testing.allocator never saw them. Now every allocation the font code makes goes through ui_malloc, ui_calloc, ui_realloc and ui_free, exported from gui.zig over `font_allocator`: - HarfBuzz is compiled with hb_malloc_impl and friends pointing at them. - Each UIFont gives its FreeType library its own memory manager (FT_New_Library with an FT_MemoryRec_ over the same functions, then FT_Add_Default_Modules and FT_Set_Default_Properties: exactly what FT_Init_FreeType does over its malloc manager), torn down with FT_Done_Library. - The shim's own UIFont and scratch arrays use them too. C's free and realloc pass no size, so a block starts with a 32-byte header (the caller's pointer stays 16-byte, max_align_t, aligned) holding its size and the allocator that made it, so a block goes back where it came from even after font_allocator is repointed. runNative points font_allocator at the gui's gpa. HarfBuzz makes a few process-wide objects on first use and never frees them (the default Unicode functions, the locale's language, hb-ft's font functions); ui_font_prime makes them from the default heap before that, so a Debug build's leak report stays about fonts. Tests do the same: the shaping tests, and a new test that creates, rasterizes and shapes fonts under std.testing.allocator, report any leak. Only the render thread calls into fonts, and every allocator used is thread-safe. It costs nothing measurable: against the previous change, 10 interleaved rounds with an A/A pair show every scenario as fast or faster with Adwaita Mono and Maple Mono alike, and first paint over 30 launches each matches main. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Shape text with HarfBuzz, so a font's programming ligatures draw across ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | their cells The SDL shell rasterized every cell by its own codepoint, so a font's `->`, `!=` or `<=` ligature never appeared. Text is now shaped with HarfBuzz 11.0.0, built from source like FreeType: a lazy build.zig.zon dependency (the tarball Ghostty pins) compiled only for the gui shell, its FreeType integration linked against the freetype package, so the binary carries no system HarfBuzz or FreeType and a tty build never compiles it. Shaping turns on only the font's ligature features (liga, calt, clig, rlig, rclt); composition, local forms and fractions stay off, so a cell draws either its nominal glyph, as it always did, or part of a ligature. A glyph no lookup of those features covers cannot change, so its cell is resolved alone, and that resolution is cached by codepoint: in a font without ligatures (Adwaita Mono) no cell is ever shaped, and the pipeline draws pixel for pixel as before. A cell whose glyph a ligature could replace is shaped with its word: the cells between spaces that share a decoration and a role. A word ends either side of the cursor, block or bar, so the cell being edited shows its own character (Ghostty's rule), and inside f|i, f|l and s|t, whose typographic ligatures do not belong on a grid (Ghostty again). Colours never end a word: a ligature spans a syntax colour change or a selection edge. A codepoint the primary font lacks shapes in the fallback face that has it. Taglines are not shaped: they are drawn at two pitches, and a strip would not line up in one. HarfBuzz only chooses glyphs; the grid places them. A substituted glyph whose ink reaches over neighbouring cells that draw nothing of their own (the spacer glyphs of Fira Code-style fonts such as Maple Mono, or the cells a merged ligature cluster swallowed) claims them: it is rasterized once as a strip that many cells wide, and each cell samples its own slice, so selection, the cursor and per-cell colours work unchanged. Anything the grid cannot place (several glyphs in one cell, a positioned mark) keeps its nominal glyphs. A frame must not pay for this. A shaped word's atlas slots are cached by its text (checked against the stored text, not just its hash), so a frame costs one lookup per word and a compare for the rest of the word being walked. The atlas is keyed by (face, glyph, role, decoration, lead, span), packed into 64 bits so a lookup hashes one word; ASCII glyph ids are a table, and a face's HarfBuzz state is made when it is first asked about a glyph. Measured with the latency trace, whose F line now also carries the submit time (CPU = submit - present entry), in 10 interleaved rounds against main with an A/A pair, at 160x50 cells: with Adwaita Mono every scenario (idle, scrolling, typing, terminal output) is as fast or 1-3% faster, and the first frame and time to first paint are unchanged. With Maple Mono, ligatures cost the first frame about 0.8 ms (HarfBuzz setup and shaping the screen) and scrolling new text about 1-2%. With it the codepoint raster path is gone: grips look up their glyph and stay centered, since a grip is not text. ui_font_scale_for_height, an identity kept as a "size token", is removed (px is the size everywhere), ui_font_has_glyph becomes ui_font_glyph, and the space slot is simply the first, cleared cell of the atlas rather than a rasterized space. CellStyle.ul gets an explicit u3 tag so a decoration fits in the packed keys. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Embolden bold text's outline before it is rasterized, and drop the old ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | stb_truetype sources Bold in the SDL shell was a smear of the finished glyph: every pixel took the larger of itself and three quarters of its left neighbour. That added a grey fringe on the right of each stem instead of a thicker stem, so bold read as blurred regular text, and at large sizes it barely read as bold at all. The rasterizer now does what Ghostty's FreeType face does: it loads the hinted glyph, thickens the outline with FT_Outline_Embolden, and renders that. The stems widen as one shape with hinted edges, and the advance is untouched, so the cell grid, caret and selection do not move. The strength is Ghostty's heuristic, 1/32 of the line height rounded up (one pixel at a 32 px line). A bitmap strike has no outline; it is widened by one whole pixel with FT_Bitmap_Embolden, the classic overstrike. Underline and strikethrough are still drawn into the cached cell mask (decorateLine). vendor/stb (stb_truetype.h and its implementation unit) was left over from the rasterizer FreeType replaced; nothing built or referenced it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* Center tagline bands, frame anchors evenly, fill to the window edge, and ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | resize columns from their grip Every tag band (workspace, column, pane) is centred in its row, so text sits at one baseline offset. Anchors are inset from the column rule by that same margin, so they are square and the band frames them on the left, top and bottom; the pane mark moves to stay centred. When the window is not a whole number of cells, the bands, rules, spines and scroll thumbs at the right and bottom edges run on through the leftover pixels. A column grip dropped short of another column's place now moves the column's left edge, with a dashed rail preview, sharing setColumnPairWidth with the border drag. A release still on the grip changes nothing, and a pair too narrow for two MINW columns is left alone. PARDES_TEST_PAD adds leftover pixels to a test-mode window and capture.
* Repaint PDF highlights by row, send rasters by shared memory, and animate ↵Gabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | messages PDF highlights (hover preview, search, selection) are baked into page rasters, and any change re-rendered the whole page with MuPDF; the TTY then re-sent it as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion over a PDF invalidated the page even when no preview was shown, so every motion paid that. Now: - A raster whose baked highlight set equals the wanted one is left alone. - A highlighted page keeps its clean rows (before highlights and tint); a change repaints only the rows of quads that differ, running MuPDF's highlight pass (pardes_pdf_paint_highlights) over those clean rows and tinting them: the operations a full render performs, so the pixels are identical. MuPDF band renders are NOT bit-identical to a whole page (edge rows, resampled images), so they are never used to patch; the comment claiming otherwise is corrected. - ImagePlace.patch hands shells the changed rows; the GUI uploads just those rows into the texture it holds. - The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and sends rasters as a /dev/shm name when the terminal reads it; direct base64 otherwise (ssh). - Shells that take row patches (GUI, TTY with shm) repaint a selection while it is dragged instead of only on release. Latency elsewhere: - TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts into the input queue, so input inside the frame is handled at once. - TTY and GUI: queued pointer motions coalesce to the last. - GUI: a skipped swapchain image re-arms the frame (3 retries); animations still tick while nothing presents. - Editing: the line index is carried across an edit instead of rebuilt from a scan of the whole file per keystroke. Messages fall into their row (ease-in; the GUI slides the band out from under the tagline, a terminal fades it), stay until the next input as before, linger MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation toggles it; both are settings, in Config and startup files. The snapshot harness pins the old behaviour. The detached server now ticks animations. A restored terminal comes back live: the old screen and scrollback (dumped as clean VT by ghostty's formatter, replayed at the new size; older dumps fall back to their rendered text), a dim "restored history" marker, then a new shell in the directory it was in. Right-click on a line number in a file pane looks at that line (a sticky context header's number included). Measured with an external pty driver (TTY), an in-process fence trace (GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity against the baseline and a whole-page oracle); balanced A/A/B rounds, paired per-round statistics. Messages stack: each event gets its own row and its own fall, linger and dissolve; a line keeps its row until it leaves and a new one fills the first free row. Announcements and statuses are replaced in place, not stacked. MessageFall, MessageDissolve and DumpDir are settings Config reports. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* gui: PARDES_TEST_LATENCY trace of input reads and frame fencesGabriel Schneider35 hours
|
* Answer 9P on the connection's task, so a session can open its own treeGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The editor's loop was the only thing that could answer a 9P request, which made the editor's own syscalls through a mount of its own tree -- a Look at /mnt/9p/pardes/<me>/anything under a `9ns --mntgen` view, a Save into it -- requests only the blocked loop could serve. The name-based refusal that followed (ownMountSuffix) and the in-process routing of a mount of oneself (Client.sameSession) were patches over that, and both are gone, with the mailbox that shipped every request to the editor's thread. One rule replaces them, `pardes.turn`: the core is single-threaded, the editor's thread has the turn by default and gives it up in two kinds of gap -- while it waits for input and while a step of it is out in a host syscall -- and a cloud9 connection task takes it in those gaps to answer. `out` counts the steps that are out, from any thread: while one is, the core reads consistently but that step still holds pointers into it, so a request that would change a pane (a write, a truncation, an rmdir) is parked in the engine and retried when the turn is next given up with nothing out, and the editor's own wake waits for the count to reach zero. It is never a write of its own that a step waits on out there -- writes come from a shell performing a save between steps -- so a parked request is never the syscall's own, and making a pane or rendering a screen need not park: every yield sits before its step's mutation, so the layout and the surface are whole under it. A changing request that queued effects is answered once the editor has performed them (`echo Save > exec` returns with the file written, as acme's `put` does), and it settles the way a step does, because without that a /log reader waited for the user's next keystroke. Every host syscall on a user path has to give the turn up, not fs.zig's alone: the first end-to-end run hung in `inotify_add_watch` performing the new pane's watch effect. PDFs and images are read whole at open, so no draw goes out into the host. The core's allocator takes its fixed buffer through the lock-free interface, since a connection task allocates while the editor's thread is out in a syscall that allocates too. A Restore puts the replacement in first and releases every task waiting on the old core. cloud9 (pinned at eb1a104) parks an open, a truncating wstat, a clunk and a remove on `again`, not only reads and writes, and answers a parked job whose fid was clunked without asking the backend. Verified: test/selfmount.py runs the editor under `9ns --mntgen` and Looks at, reads and Saves its own tree through the mount; a unit test pins that a change parks while the editor is out mid-step and lands when it rests, while a read is answered in the window. 9P over the Unix socket against a tty session, same machine, Debug builds: a read of /index 278us -> 61us, a truncating body write 1184us -> 609us, exec Save 718us -> 583us; the gesture benchmark is unchanged (geometric mean 0.997 over 53 cells). Also from the reviews: a notice chip over an image or PDF pane was painted out by the picture drawn after the cells, so pictures give up the rows; in the GUI a tree-sitter context band painted over the chip, so body layers are emitted first; a message is one row of printable text, its 256-byte cut never leaves half a glyph, and one wider than its pane keeps its tail (the file name, the reason) rather than its head. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Notices become chips that float over the body, not rows taken from itGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | A notice is an overlay now, which is the one place it is NOT like the tree-sitter context: it takes no row from the body and moves no text. Each one is a chip as wide as its own message plus a blank cell either side, anchored to the pane right edge on the body top rows. The rest of each of those rows is ordinary body text that still reads and still answers a click -- Notices.left records where each chip starts, and the hit test refuses only the cells it covers rather than the whole row. The width is counted in GRID columns rather than scaled into the tagline face the chip is drawn in, because the canonical grid is what a terminal client draws and a chip narrower than its own text there would clip it; the narrower tagline face simply leaves a little more room inside the chip. The tag layer takes the geometry the grid pass already chose, so the two cannot disagree and the GUI skipping the cells a tag layer covers leaves no stub behind. Pane.body_offset is gone with the reservation it existed for, and bodyTop takes only a rect. The body layer keeps every row it had, the scrollbar runs the full body again, and the GUI smooth-scroll extents go back to the pane. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Notices become tagline bands at the top of the bodyGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A message, a leader chord and a prompt used to share one row of body text at the bottom of a pane, wearing the tagline font and nothing else about a tagline. Now each one is a TagLayer of its own, emitted through the same renderHeaderLayer the pane and column tags go through, so it gets the tagline height, the small-font metrics, the band offset and the border for free -- none of which a body-grid row can have by changing its font role. The text is right aligned. The prompt stays on the canonical grid because it owns a cursor, and a cursor has to sit on a real cell. The body starts BELOW the bands rather than under them, the way tree-sitter context rows already worked. Pane.body_offset is how many rows they took and Pardes.bodyTop(pane, rect) is the one place that answers "where does the body begin" -- replacing fifteen copies of `if (tag_bottom) r.y else r.y + BOX_H` spread across the paint, hit-test, scroll, PDF and image paths, which is what let the bands and the text under them come adrift. Every notice is painted on the grid as well, because the grid is what a terminal client draws and a band it cannot see is a message it never gets; the GUI skips grid cells a tag layer covers, so nothing is drawn twice. Three bugs the bands exposed, fixed here: - a prompt band flush with the right edge put its caret one column past the pane, which the detached wire refuses -- so every frame was dropped for as long as the prompt was open. The band now reserves that column. - a click on a band mapped to Sel row 0, which is the TAG row: clicking chrome expanded a word out of the tagline and ran it as a builtin. - a watched file reloading under the editor changed the core without going through update, so needs_frame was never set and the reload was never drawn. Pardes.invalidate() is the name for that, and the file and theme reloads call it. A session can now drive its own 9P namespace instead of being refused one: ownMountSuffix answers what a path names inside this editors own tree and resolve, readLimit and write serve it from memory rather than making the syscall that never returns. The match is anchored to whole components under the registrys 9p/pardes/<name>, because a bare /pardes/<name> anywhere in a string would claim ~/src/pardes/<name>/README -- and, before write learned the same trick, write the trees bytes over the real file. readFileLimit and writeFile refuse instead, having no core to answer from. A toggle setting SETS when given `on` or `off` and only flips when it is bare, so the report LocationsConfig prints can be fed back as configuration and mean what it says. Snapshots: 97/98, from 0/98. The goldens were several commits stale and 17 scripts had stopped running; `config <line>` is a new script command that appends to the per-script startup config, so a script that clicks body coordinates pins `Verbose off` instead of counting the rows an announcement moves. nested-optout is left failing on purpose: two levels of nesting prepend vaxis F3 codepoints to typed lines, which is a real bug and is written down in docs/divergences.md with a repro. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Make the macOS shell a first-class hostGabriel Schneider35 hours
| | | | | | | | | | | | | | | | | | | | | Pty children are exec'd with their own TERM/COLORTERM/TERM_PROGRAM instead of inheriting a .app launch's empty environment, and ttyTaken finally answers on darwin — libproc walks the tty's foreground process group — so Escape reaches the child and Exec stops believing every pane sits at its prompt. The occupancy suite runs on both platforms now. The workspace tag row moves into the native menu bar as a Builtins menu. -Dworkspace-tag (default off for -Dplatform=macos, on everywhere else) drives it, and Pardes.topBarHeight replaces the TOPBAR_H constant so the core stops reserving the row. The view pins every variable-font axis to the file's own default (Maple Mono came up Thin otherwise), shapes ligatures, carries per-shape pointer cursors, and draws the look-hover affordance as refracted glass. Tag rows fill edge to edge, with the anchor box painted back on top of that fill and its mode glyph centred on the same square. Theme accents re-saturated across the set. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Serve Unix and TCP 9P through cloud9.serve's std.Io runnerGabriel Schneider35 hours
| | | | | | | | | | | The hand-written poll loop for Unix/TCP listeners is replaced by cloud9.serve.Runner; requests are queued to the editor thread, which answers them under the connection lock on each frame and retries parked reads as before. QUIC keeps the poll path (its adapter is fd based). The detached server no longer loses a wake that lands between frames. The firmware path keeps driving the engine with push/step. cloud9 re-pinned. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Center GUI pane indicators using cached font ink boundsGabriel Schneider2026-09-15
|
* Separate pane and column grip hit areas from tagline text with a small gapGabriel Schneider2026-09-15
|
* Keep column grips blank and muted in every focus stateGabriel Schneider2026-09-15
|
* Reorder columns using aligned and distinctly colored tag gripsGabriel Schneider2026-09-15
|
* Separate tag text geometry from physical pane gripsGabriel Schneider2026-09-15
|
* Separate discontinuous pinned source context with thin bordersGabriel Schneider2026-09-15
|
* Add optional compact tagline styling for source contextGabriel Schneider2026-09-15
|
* Follow embedded PDF links through LookGabriel Schneider2026-09-15
|
* Route mouse thumb buttons to jump historyGabriel Schneider2026-09-15
|
* 9p: use cloud9 protocol sessions and transportsGabriel Schneider2026-09-15
|
* trunk: resume before the Reload experimentGabriel Schneider2026-09-15
| | | | Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
* Resolve relative restores in the dump directory and propagate LSP probe errorsGabriel Schneider2026-09-15
|
* Refactor panes and filesystem; replace FUSE with 9PGabriel Schneider2026-09-07
| | | | | | Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
* messages: a fixed log of what the rows said, and a word to read it backGabriel Schneider2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A message row is cleared by the next keystroke, so anything reported while you were looking at another pane was gone before you could read it — a save that failed, a watcher's reload, a builtin's complaint. `setMessage` now records into a fixed ring first: no allocation and no failure path, because it sits underneath `reportError`, which is reached from sites that are reporting an allocation failure. `Messages` (`SPC h m`) reads it back oldest-first. Three things an adversarial pass found, each of which defeated the feature: PROGRESS IS NOT A MESSAGE. A language server emits `Indexing 47%` several times a second, and every tick is a distinct string BY CONSTRUCTION, so no de-duplication can collapse it: at the client's one-per-150ms throttle it takes about nineteen seconds to push every real message out of the ring. A log that one indexing run empties is not a log. That path is `setStatus` now — the row, and nothing else. THE CLOCK MADE EVERY HOST MESSAGE UNIQUE. `message.stamp` prefixes `HH:MM:SS`, so `saved /x.zig` at 14:32:07 and at :09 compared unequal and the ring filled with rows that look identical and each say (x1) — exactly the case the de-duplication exists for. It compares `message.body` now, the row without its clock, and the newest wording wins so the row carries the last time it happened rather than the first. It also keys on the PANE (one pane's failure must not be recorded as another's) and compares the truncated form, so two identical messages over 256 bytes stop being two rows. AND THE CAPACITY BELONGS IN limits.zig. 128 entries is 32.75 KiB that is allocated whether or not anybody reads it — 8.5% of the ESP32-P4's whole 384 KiB heap, about the size of its effect ring. The board takes sixteen. The builtins/leader goldens move because the listing gains a row, and builtins.snap middle-clicks a SCREEN COORDINATE that Tutor moved out of; both updated selectively and verified against a fresh run. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* pipe: a filter that fails says so, and `| head -1` stops failingGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things made the selection pipe feel like it had never worked. It runs — test/snapshots/pipe.snap drives the real binary through a pty and filters `alpha beta` to `ALPHA BETA` — but it had no way to tell you when it did not, and one of its failure conditions was not a failure at all. IT NOW SAYS WHY. `runOne` read the command's stderr into memory and freed it two lines later, unread; every caller answered a failed filter with a bare `return`; `pipeResponse` had eight more silent exits under that. So `| trr` (a typo), `| grep nomatch` (exit 1), `| jq .` on bad JSON — all did nothing, said nothing, and left the text alone with no way to find out why. The runner carries a `Failure` home instead: which selection, what became of the command, and its own stderr. The core turns that into an `+Errors` buffer — acme's name for output that came from the program rather than from a word anybody clicked: | trr exit status 127 sh: line 1: trr: command not found An output buffer rather than the message row because the useful half of a shell failure is the text the shell wrote, and a 256-byte row would keep the label and throw away the reason. Focus stays with the file: `openRead` moves `p.active` to what it opens, which is right for a Grep you asked to read and wrong for a report you did not — you want to fix the command and press `|` again. A host with no `pull_pipe` at all (the detached daemon, the browser, the board) now says that too, instead of answering failure into the void. `| head -1` NOW WORKS. `writer_context.ok` was part of the success condition, so a command that stopped reading its stdin failed the filter even though it had done exactly its job: `head` takes the line it wants and closes the pipe, the write gets EPIPE, and a selection bigger than the 64 KiB pipe buffer was enough to trigger it. helix joins its input task and ignores the result for this reason; the exit status is the whole verdict. Also reported rather than swallowed: the ten-second timeout, the output ceilings, and a file edited while the filter ran — one keystroke during a slow command used to discard the result in a way indistinguishable from the filter doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* errors: a save that could not happen, and two panics on an ordinary clickGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A review of what this program does when the environment says no. The finding that reframes it: there were almost NO panics on ordinary paths — the rule already held — but there was a great deal of silence, and one case worse than any panic. SILENT DATA LOSS ON SAVE. `saveFile` marked the pane saved the moment it QUEUED the effect, before any host had tried; `host_io.writeFd` returned void, so a short or failed write was indistinguishable from a complete one; and `writeFileBytes` returned true regardless. A save to a read-only file, or into a directory removed under the pane, therefore cleared the tag's ` *` and posted nothing — and `Del` makes no dirty check, so the next click threw the edits away with the screen saying they were safe. On a full disk it was worse: the file is already `O_TRUNC`'d when `write` fails, so the message row said `saved` over a file that had just been emptied. Now: `writeFd` reports, `writeFileBytes` returns WHY (`PermissionDenied`, `NoSpaceLeft`, `ReadOnlyFilesystem`, …) including a failed `close`, which is where write-back filesystems report at all; the core marks the pane saved around `perform` rather than at emit, which is also where the bytes are read; and a host that could not write calls `Pardes.saveFailed`, which puts the reason on the message row and takes the clean mark back. That is a CALL and not a return value because host.zig enforces, at comptime, that a `push_` method reaching every host in a fan-out cannot have one answer — the first attempt at this changed the signature and the compiler was right to refuse it. TWO PANICS ON AN ORDINARY KEYSTROKE, in look.zig's number scans. `v = v * 10 + d` over caller-supplied digits, reached from `parsePathLine` and the `@pN` scan — which every Look, every right-click and every n/N motion runs on whatever word is under the pointer. A hash in a log, a CSV column, any output shaped `foo:99999999999999999999`, and the editor died with "integer overflow". Both saturate now, the same way acmefs.zig's address parser already did; a saturated line is refused by `file_pane.open`'s `line <= total` and a saturated pane id by `focusPaneLine`'s `id < MAX_PANES`, so nothing addressable changes. A BOOT FILE THAT WILL NOT OPEN joins the missing-name case in the `+Errors` pane instead of taking the launch down: `pardes /root` resolves as a `.file`, could not be read, and left `error: PermissionDenied` and a return trace. `look.readFile` now says which errno it was, so the pane can say "permission denied" rather than a word from the source code. The tag-marker test drained no effects and passed anyway, which is exactly the defect; it drains now. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf