summaryrefslogtreecommitdiff
path: root/src/detached/wire.zig
Commit message (Collapse)AuthorAge
* G5: lapis, 0x4200.cafe's manuscript, as a theme with its ornamentGabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A native theme `lapis` (src/themes/lapis.zig) from web/theme/lapis.css: a lapis-deep page, lapis tags with gold names, vellum text, vermilion rubrics, gold rules and a vermilion tag rule, gold selection in lapis ink. Its ornament is the theme's new `decor` (colors.Decor), drawn by a pixel shell inside the chrome it already has (decision 1), all of it off every focus indicator: - page_dots: the site's gold dot grid (#e8c46a22, 22 px), drawn by the cell shader on page-coloured cells alone (flag 0x10000000), so it lies under the text and never under a selection, a tag or the cursor; fixed to the window, a page the panes lie on. - rail_checker: the dither frame's gold and lapis checker in the scroll track (a decor kind, flag 0x08000000), anchored to its rail; the thumb stays solid gold. - tag plaques (tag_border, tag_shadow, tag_stripe): every tag a raised plaque inside its own band, the site's trail: a 2px gold frame and a hard 5px vermilion shadow down and right, both within the band's rows and columns (down, what the slack under the text leaves), so no shadow reaches a body, a grip or the next pane. The focused pane's plaque is the code window's title bar, gold and lapis stripes of 2px, its words on plates of the focused ground; unfocused ones are plain; column and workspace tags take the object-label language (1px frame, 2px shadow toward the page). Drawn as decor under the cells (a group's under range), with blank tag cells see-through and the cells blended premultiplied (pipeline_over) on such frames; opaque windows only. - title_shadow: the titles' vermilion offset shadow under a file name's letters, sampled in the cell shader inside the letters' own cells (flag 0x08000000), none on a cursor. Chrome carries the decor (decor_*, name_ink, active_name_ink); a theme file folds a nested decor literal field by field. Left out, with why, in docs/effects.md: the fonts, the ornaments, a decor crossfade, and in the tty anything but the colours (a tag row has no spare cell for a shadow). An empty column is the page. The effect id is masked to 27 bits (ui.vert/ui.frag) for the fifth flag. Tests: the cell flags land only on page ground and file-name cells, never a cursor, nothing without decor; lapis's plaques stay inside their tag's band, clear of every grip, only the focused one striped; its checker is in the track; no other theme draws any; the separator and focus tests cover lapis. Goldens: theme and themesel (the ring gains lapis after acme). Gates as G4. Feel review: .scratch/render/lapis/ (lapis.png, lapis-2x.png, tags-focused-vs-unfocused-2x.png, a PanelSlide at 60 fps and a quarter).
* G3: the focused cursor glides a jump on the Motion flavour's springs, and blinksGabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The core owns the motion, as Lift: animation.CursorGlide keeps the focused cursor's four corners on springs (leading corners at the flavour's pace x2.4, trailing ones x follow, so a jump smears and collapses; a long jump bows by the flavour's arc), sampled at each frame's own time. A step of a cell or less, insert mode, a focus change and any scroll (a file's view, a terminal's output) land at once; a snap lands a glide under way too. The quad is clamped to the focused pane when drawn (never its springs). Chrome carries it on the wire (cursor_glide: bit 1 a cursor, bit 0 gliding; cursor_alpha; cursor_quad, grid cells; cursor_idle_ms): the GUI draws the quad as an overlay in the cursor's colour, at 85% (70% on the long flavours) rising to solid as it lands, and hides the cells' cursors while it does: one or the other every frame. Only the focused cursor glides. Blink (CursorBlink, on by default in pixel shells) is the shell's: from cursor_idle_ms and when the frame arrived, solid while typing and 500 ms after, 530 ms halves with 80 ms eased edges, solid after 10 s idle, and never while the window is unfocused or minimized or on a virtual clock. An edge redraws the last frame with no core render (level B, blink_redraws); an attached GUI blinks from the same field without a frame per edge. Tests: snaps a cell, glides a jump with smear and bow, arrives under a pixel by 150 ms on smooth and lands exactly, shifts with a scroll; blink holds, eases, stops; the core glides a jump (ge) frame by frame (gliding exactly while it moves, alpha rising, exact at rest), snaps a step (j) and a switch to insert mid-glide; a terminal's scrolling output lands its cursor; wire round-trip of the cursor record. Shared files: src/animation.zig, src/pardes.zig, src/config.zig (toggle CursorBlink), src/surface.zig, src/detached/wire.zig.
* acme looks like acme; every theme gets acme's button, scroll column and 2px ↵Gabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | rules acme takes plan9port's own colours (acme.c iconinit, draw.h): selections #eeee9e in the body and #9eeeee in the tag, black ink; exec and look sweeps #aa0000 and #006600, white ink; a #99994c scrollbar under a #ffffea thumb; black rules; a #8888cc tag/body rule and column button. Every theme: the grip is acme's button, its tag row's full height, in one scroll column with the scrollbar (rail_px, 12 at a 17px tagline, scaled, times GripWidth, a percent, default 150; same x0/x1 to the pixel): focused clean = solid box; focused dirty = box_dirty filled to a box ring (acme's modbutton); unfocused clean = a box_border ring round the tag's ground; unfocused dirty = box_dirty a pixel inside that ring. The grid, which has no ring, marks dirty with a bold * on box_dirty in the grip's second cell and fills an unfocused grip halfway to its ring. Regions carry a dirty flag. Column buttons are the same shape, solid. 2px rules between columns and stacked panes (inside the tag band's slack), none at the window edge; a 1px tag/body rule halfway between tag and page. Theme data: tag_sel_bg, sweep_bg/fg, tag_rule, rule_px, rail_px, box_border, box_dirty. Chrome: rule_px, tag_rule, rail_px, grip_border, grip_focus_ring; a comptime guard on Chrome's layout forces a wire bump once v8 ships. Structural rules are exempt from the focus-indicator rule (docs/effects.md). Tests: acme widths; stacked rule in the slack at two tagline sizes; every grip-column pixel is band, button, ring or rule, and the button shares the scrollbar's x0/x1 (4 metrics x top/bottom x focus x dirty); the four grip states distinct in every native theme with a non-colour dirty cue in the grid; wire round-trip of the new fields. Shared files: src/colors.zig, src/surface.zig, src/body_layer.zig, src/tagline.zig, src/detached/wire.zig, docs/themes.md, docs/config.md, docs/effects.md, 21 snapshot goldens (the grip cells only), test/gui-goldens.txt. Motion flavours differ by design, and drive notice drops and pane moves
* Motion flavours, and Lift is shadow, rim or auto: auto on a dark page ↵Gabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | recedes the other panes (InactiveDim) Motion off|crisp|smooth|bouncy|playful is one parameter set (animation.Motion) every fx animation reads. One focus spring per pane drives both the lift and the dim, at the flavour's pace; a grid snaps. Lift drops glow and surface. A shadow falls on pane bodies and rails only, capped so text and the selection keep min(contrast, 4.5); rim is a hairline along the focused tag's top. auto is a shadow on a light page; on a dark page the core recedes the unfocused panes' text (InactiveDim, ported from 2e4d97f, default 30 under auto) with the same floor. Tests: focused text contrast is never below unfocused in any style, dim or theme; the focused pane's cells and tag are untouched by a dim. Shared files: src/config.zig, src/builtins.zig, src/ninep/ctl.zig, src/surface.zig, src/detached/wire.zig, docs/config.md, test/snapshots/builtins.snap (one more wheel for the longer help).
* Lift takes a style: shadow, surface, rim, glow or autoGabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | The user wants Lift to read on dark themes, where a shadow has nothing to darken. `Lift shadow|surface|rim|glow|auto` (`on` is auto, bare flips auto and off): surface lightens the lifted part's ground about 5.5% (Material-dark), rim is a thin soft light off its top and left edges, glow a faint halo in the theme's accent (Chrome.accent, the focused box colour), auto a shadow on a light page and surface with half a rim on a dark one (by the page's luminance). Rim and glow are the soft kernel in a light colour (no pre-warp: coloured light is laid on as is, within §8.2's budget); each style is capped per theme so text keeps min(its contrast, 4.5) (tests: darkening and lightening over the native themes). The style travels in the palette (Chrome.lift_style, one byte on the wire). Config reports and ctl reads `Lift <style>`. Shared files touched: config.zig, builtins.zig, ninep/ctl.zig, detached/wire.zig (palette byte), gui.zig. Not touched: pardes.zig, Messages.zig, mouse.zig.
* G1: Lift, soft elevation shadows (off by default)Gabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | `Lift` (a GUI toggle, capability lift) lifts the focused pane on a critically damped spring (animation.Spring: closed form, about 240 ms to settle, velocity kept across retargets, settled = no frames) and floats notices. The core puts the lift on the regions (Region.lift, on the wire); the GUI casts from it: a soft shadow is decor, an erf rectangle on one quad grown by 3 sigma (decor.frag, flag 0x10000000), in the quad's own space so a track's transform carries it; darken-only, the caster's rect left alone, alpha pre-warped for linear light and IGN-dithered. Cast last in its group, so a neighbour's rails and rules darken with its text; a notice's before its rule. Strength is capped per theme so text over the darkest shadow keeps min(its contrast, 4.5) (test over the native themes). Opt-in until the focus lift/dim default is decided. Feel review material: docs/effects.md. Test-mode PARDES_TEST_SERIES keeps every captured frame. Shared files touched: pardes.zig (two fields, one nextWake line), detached/wire.zig (lift on the region), config.zig, builtins.zig (capability), gui.zig. Not touched: Messages.zig, mouse.zig, tty, host_io.
* Trial merge: 9P + helix + renderGabriel Schneider38 hours
|\
| * The GUI draws in tiers from the regions; pane chrome moves with its paneGabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig.
| * One Layer for tags, notices, headers and bodies; a taller tag is one layer ↵Gabriel Schneider38 hours
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | of rows; wire v8 src/Layer.zig merges TagLayer and BodyLayer. `rows` (0 = no layer) and a cursor at {x, y}. A tag of N rows is ONE layer of N grid rows: tagHit answers the row as `line`, bodyHit keeps its meaning, and the per-line layer bases (TAG_LINE_LAYER_BASE, HEADER_LINE_LAYER_BASE) are gone, not aliased. Wire v8, the one bump: tag layers carry rows and cursor y, and the frame carries the placed region list. v7 and v9 peers are refused in both directions (server test over both, a new frontend test over both). web: tag_layer_value 11 = rows, 12 = cursor y; app.mjs lays every row. macOS: the Zig side compiles against Layer; pardes.h still sees one row per tag layer (accepted, the macOS shell is ignored for now). No visual change: snapshot goldens and the 17 GUI goldens byte-identical. Shared files touched: pardes.zig, Messages.zig, gui.zig, macos.zig, detached/client.zig, detached/server.zig, detached/wire.zig (plus web.zig, app.mjs, edit.zig, look.zig). Not touched: mouse.zig, tagline.zig, colors.zig, tty.zig, dump.zig, exec.zig, host_io.zig, panes.zig.
* | A command pane's command is over when its process exits, not when its pty closesGabriel Schneider38 hours
|/ | | | | | | | | | | | | | | | A job left in the background (sleep 100 &) held the pty open, so the pane stayed running and the child a zombie until the job ended; a command that closed its terminal and ran on got its end of file at once, the host waited 100 ms for an exit, reported exit ? and hung it up. Now each command's child is watched on a thread (waitid with WNOWAIT, so its pid stays its own until the host reaps it), and the host tells the core the exit from that: after the pty's end of file, so the output before the exit is in, or 50 ms after the exit without one, a job holding the pty. The pty stays open until both, so a command that let go of its terminal is never hung up by it. All four front ends; a host that cannot start the watcher reads the exit at end of file as before. Tests: host_io's for both cases, and cmdexit.snap end to end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Point docs and comments at the files code moved toGabriel Schneider38 hours
| | | | | | | | | | | | | Documentation only, no code change: README's reading order and layout, docs/design.typ's paragraph on where pane kinds and editor parts live, docs/helix-keys.md's code map (normalInput and the executors now in normal.zig and edit.zig, insertTab in edit.zig), docs/open-questions.md (execute and ttyForDir in exec.zig), and the comments that named pardes.zig for fold, Cell, takesCommandLine and runBuiltin (tools/gen_themes.zig, themes/helix.zig, detached/wire.zig, host_io.zig, lsp_zls.zig). docs/design.pdf is a retained fixture and is not regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Answer 9P on the connection's task, so a session can open its own treeGabriel Schneider38 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 tagline bands at the top of the bodyGabriel Schneider38 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]>
* 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
|
* 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.
* detached: a big screen can attach, and the frame it asks for fits the wireGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects, and either fix alone makes the other one worse. No frontend ever clamped its window to the protocol's grid ceiling — the hello carried it raw — so a 4K display at a small font, already past max_rows 128, had its geometry refused by the session's decoder as BadValue. That path answers with close(.protocol) and no refuse behind it, so the frontend was told only that the session "hung up on the connect": at a session with all 32 slots free. client.zig now asks for the largest grid the wire carries, which is what its own GEOMETRY note already promises a frontend gets — the session is drawn at its own size in the corner of a bigger window, exactly as when another frontend is the smaller one. A ZERO geometry is dropped rather than clamped, because the session grid is the smallest common one and a frontend reporting 1 would collapse everybody else; TIOCGWINSZ answers 0x0 during a teardown and the tty shell forwarded it, which was the same mute hangup by another route. That clamp alone would have replaced one bug with a worse one. max_cols * max_rows is 65536 and a run's length prefix is a u16, so the single grid legal at both bounds is the one grid whose full frame — and an attach always produces a full frame — cannot be described by one run. encodeFrame's @intCast panicked in a safe build and was illegal behaviour in a fast one. The encoder splits the run instead, bounding the CURSOR rather than the run because the gap lookahead runs ahead of it, and frameBound had already paid for the extra header. wire.version 1 -> 2 for the same reason: the geometry a v2 frontend now asks for is one a v1 daemon panics encoding, and `zig build` replacing the binary under a running session is exactly what that field exists for. A v1 daemon answers Refusal.version instead of dying with every pane shell it owns. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* acmefs: a pane's terminal gets pty/data, pty/ctl and pty/statusGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 3 of the 9P chain (docs/9p.typ 12.3, docs/registry.typ 9P-8). Nothing here is about 9P: it lands in the FUSE-served tree and any later transport inherits it. A script could write into a terminal that already existed and read its rendered scrollback. It could not START one, RESIZE one or SIGNAL one. Two of those were already effects the core emits, so `exec` and `winsize` are existing capabilities acquiring a name; only `sig` is new, and it brings the one new host method, `push_pty_signal`. pty/ctl winsize <cols> <rows> | sig INT|TERM|HUP|QUIT|KILL | exec one verb per line, validate-all then apply-all, EINVAL applies nothing -- `writeCtl`'s shape and `writeCtl`'s reason pty/status cols, rows, tty-taken as three %11d fields pty/data write is input to the process; read is the RAW output stream, gated on a reader count so a pane nobody reads costs one branch A pane that is not a terminal has no pty/ at all: the lookup is ENOENT and readdir does not list it. `PaneFile` is an enum(u4) and this takes it from 11 values to 15. ONE REMAINS. That is also why pty/ is a DIRECTORY and not three more flat names -- a subdirectory costs one value and buys its own namespace, so `ctl` and `data` did not have to be renamed. Two things the core does not know, and which are therefore not invented: a child's EXIT STATUS (a shell's death is `Event.eof`, which removes the pane, so there is no directory left to read it in) and RAW/COOKED (the core never sets a termios; the mode belongs to the program on the far side). Verified live against a daemon: pty/ appears only on the terminal pane; a `winsize 0 24` and a `sig SIGINT` are refused; a bad verb beside a good one applies neither; `echo pty-works` written to pty/data runs in the shell and its output reaches the body; and a blocking read of pty/data returns the raw stream, OSC 133 marks and all. fs-bench unchanged and still zero allocations.
* An edited row keeps its colours, four copies of forkShell become one, and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc stops recentring ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
* One core behind N frontends, the board's own runner moved in, and every ↵Gabriel Schneider2026-08-27
board cap on one screen ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.