summaryrefslogtreecommitdiffstats
path: root/src (follow)
Commit message (Collapse)AuthorAge
* compact: the minimised bar sits below a shared line, not on the borderuser2026-09-09
| | | | | | | | | | | | | A compact pane owns no border cells -- its bottom border is the shared line one cell outside its rect. With panes minimised, that cell was the bar's top row: the chips sat directly on the bottom panes' content, reading as frames cut into the pane. Classic panes carry their own border row and never collided. The layout now reserves one extra row above the bar in compact, and draw_compact_lines strokes the boundary there: it joins the outer ring with real junctions, a divider from above ends into it, and a focused bottom pane re-strokes its stretch like any shared line.
* pane: shift consumed by the character is consumed, tell the encoderuser2026-09-09
| | | | | | | | | | | | | | | 935fb7e9 taught the legacy decoder to read the shift out of shifted punctuation, so / and ? could be two keys. But pane_send_key never set the consumed mods on the re-encoded event, and libghostty's kitty path only passes text through as text when no unconsumed modifier remains: a kitty-protocol pane (nvim) received : as CSI 59;2u -- shift+semicolon, no text -- and the command line never opened. :wq! was unreachable. Mark shift consumed when the event carries text, shift is held, and the text is not the unshifted character -- the same call a real terminal makes. Text the shift produced goes through as text; chords whose shift is real (shift+up, ctrl+shift+a, back-tab) keep their escapes, and a legacy pane sees no change.
* search: a scrollback search bar on /, matches highlighteduser2026-09-08
| | | | | | | | | | | | | | | | | | | | | | | | C-a / opens an inline search bar on the focused pane's status row, on lib-vt's GhosttySearch (D4's pattern: the scrollback walk, reflow and alt-screen survival are the library's, the UI and the wiring are ours). Type to search live; the viewport follows the current match; up/down (or C-n/C-p) step through them with wraparound; Enter keeps your place, Escape snaps back. A k-of-n counter reads on the right, or "no matches". Every match on screen is tinted, and the one the bar is on is tinted apart -- warm gold by default, painted after the shader pass so the scrolled wash does not dilute a match you are hunting for. Four themeable colours (search_fg/bg, search_cur_fg/bg). Bare / took the key from help, which keeps ?; that needed the legacy decoder to read the shift out of a punctuation byte the way it already did for capitals -- without it ? and / decode identically (SLASH, no mods) and cannot be two keys on a client without the kitty protocol. tests/test_search.py drives it end to end (39 checks): the bar, the counter, stepping and wrap, all-match vs current highlighting, Enter/ Escape, new output updating the count, case-insensitivity, the keyboard belonging to the bar, click-away, and that / and ? no longer collide.
* graphics: no cell arithmetic at the far edge of an uncut imageuser2026-09-07
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A smooth bounce shimmered: anything placed with sub-cell X=/Y= offsets had its source rectangle shaved by a different sliver every frame, so the picture's edges breathed as it moved. dvd.py made it obvious -- the logo's crop wobbled between w=124 and w=130 around a true 142. The pixels-per-cell a placement reports are its own pixels over its own grid, floored, and the grid includes the cell the sub-cell offset spills into, so the figure runs a shade small -- 142px over 19 cells says 7 when the terminal's cell is 8. That was harmless while the number was only used proportionally at real cut edges. The pieces rewrite ran it over every placement: axis_crop() clamped even an uncut image's source to cols * cell_px - offset, and as the offset moved the clamp moved with it. The far edge of a piece that reaches the placement's last cell is where the source runs out; only an interior cut needs the cell arithmetic. With that, an uncut placement's source rect passes through untouched, which is what the old emitter always sent. Two companions in the same sum. A scaled piece so narrow its share of the source rounds to nothing now keeps the pixel it is inside, because w=0 on the wire means "the whole image", which is worse than wrong. And a cut piece whose source is genuinely empty is cells the image never reaches, so it is not placed at all -- the uncut placement stays exempt, since the pane-edge crop has always used 0 for "the rest of it". The headless check places with X=5: the whole source rect must survive the offset, where the clamped code sent w=0.
* graphics: a retransmit invalidates every placement of the imageuser2026-09-07
| | | | | | | | | | | | | | | | | | | | | Replacing an image's pixels deletes all of its placements on the client -- kitty drops them when the data is replaced, which is the very fact the place-once logic leans on. But the invalidation rode a local in one gfx_place() call, so only the placement said in the same breath as the transmit was owed again. The image's other placements -- a second one elsewhere, the other pieces of one a float has cut -- were judged unchanged against a client that no longer holds them, and stayed unsaid: a picture the client deleted and we believe it still has, gone until something moves it. Masked before place-once by the every-frame re-emission, so this is the regression that fix bought. The transmit now un-marks every placement of its image, and each is said again as its gfx_place() comes around. The live check drives one image placed twice, then replaces its pixels and places both again: both must reach the client after the second transmit, where the old code re-sent one.
* Merge branch 'graphics-fixes' (PR #2 from iksteen): five fixes to the kitty ↵user2026-09-07
|\ | | | | | | | | | | | | | | | | | | passthrough, found through a mirror One synchronized message per frame with cells and images together, a placement said once rather than every frame, z=0 stated so a mirror knows where the picture goes, a float cutting an image into pieces instead of erasing it, and overlays cut out of the images under them. Found running slosh inside shellglass, real on a terminal too.
| * app: cut the modals, toasts and splash out of the images under themIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Overlays are painted over the panes and the compositor needs no more than that: painted later is on top. A placement is not painted, though -- the client's terminal draws the image over the cells, in whatever order they arrived -- so an overlay that is only drawn is an overlay an image sits on top of. The cheatsheet came up under a picture and was unreadable through it, in the terminal and in the mirror both. So a frame now records where its overlays landed, and the graphics pass takes them out of a placement exactly as it takes out a float: the modal's box (the cheatsheet and the picker wear the same frame), each toast, and the splash. The list is rebuilt every compose, so a rect that is not registered again is one that is no longer on screen. Only the opaque things are registered. The scrim behind a modal tints what is behind it rather than hiding it, and an image cannot be tinted by us at all -- so a picture beside the cheatsheet stays bright while the text around it is pushed back, which is wrong-looking but a great deal better than the image disappearing while a modal is up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
| * graphics: place what a float leaves of an image, not nothingIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Occlusion for placements is clipping: the cell compositor gets it free from paint order, but images are sent after the diff and drawn over it, so what a float covers has to be taken out of the placement itself. A clean edge came out as a smaller rectangle; anything else -- a float across the middle, a float on a corner, a float wholly inside -- was refused, on the grounds that the remainder is an L and a placement is a rectangle. The image vanished, all of it, the moment a floating pane crossed it, on the terminal and in the mirror alike, and came back when the float moved off. The remainder is not one rectangle, but it is always a few: the strips above and below what is covered, and what is left beside it between them. So rect_subtract() cuts the visible remainder into rectangles and each is placed in its own right, with the source rectangle the same arithmetic the pane edge already used -- generalised to a cut on any side, and to a first piece that keeps the sub-cell offset while the others start on a cell boundary. Eight pieces is the ceiling (two floats' worth); past it the old refusal stands. The pieces of one placement need an id each, and the program's own placement id is one number that cannot be several, so the model now hands out the placement ids the client is told and keeps the program's as the identity it was. `graphics` reports the piece alongside it. Checked against a float parked over an image: the picture is cut around the float, the strips tile exactly the image minus what the float covers, and their source rectangles land where their cells do (a float inside a 40x10-cell natural-size image gives 320x32 above, 320x32 below, 56x96 left and 72x96 right). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
| * graphics: state z=0 on a placement, so a mirror paints it over the textIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | kitty's default z is 0, which draws an image above the text, and that is the value slosh has always relied on -- it places after the cell diff precisely so the picture ends up on top. Saying nothing works because the terminal supplies the default. Anything mirroring the stream has no such default to fall back on. It cannot tell "the emitter wants this over the text" from "the emitter said nothing", and shellglass's viewer paints an unstated image under the cells. That is fine until a cell over the picture carries an explicit background -- and dim_unfocused gives one to every cell of a pane at once, so the moment focus moved elsewhere the whole image disappeared from the mirrored copy while the terminal, which took the default, still showed it. So the placement states z=0. Nothing changes on a terminal, since that is the value it was already assuming; the mirror now paints the picture over the cells and the dim pass runs underneath it, where it belongs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
| * graphics: place an image once, not once per frameIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every frame re-emitted `a=p` for every visible placement. On a terminal that is invisible: a kitty placement is a standing instruction, not a per-frame one ("the other commands to erase text must have no effect on graphics"), so a repaint of the cells under a picture does not disturb it and saying it again says nothing new. It is not free anywhere else. A mirror cannot know that the identical placement is identical: shellglass re-decodes and re-encodes the image on every one, holds the placement back while that runs, and -- because an image change rides only in a full frame -- republishes the whole screen each time. Measured on one image and a pane printing text for twenty seconds, the mirror published 430 full frames and re-sent the image list 215 times; 215 of those fulls carried no image at all, because the placement was still mid-decode. That alternation is what was visible in the browser: a picture that flickered while anything on screen moved and vanished when it stopped. The same twenty seconds now costs one full frame, 412 diffs and one image list. So a placement is emitted only when it is new or has actually changed, and the client's state records what it is holding. Three things have to be right for that to be safe: a dropped frame un-marks the placements it carried (they are owed again, exactly as transmissions and deletions already were); a retransmit of the pixels invalidates the placement, because kitty deletes an image's placements when its data is replaced; and a full repaint clears the screen, which takes the images with it, so every placement is owed again -- gfx_repaint(), not gfx_reset(), because the pixels the terminal was sent survive a clear and a deletion still owed from a dropped frame is still owed. The live check drives a picture that does not move while the screen around it repaints: one placement across 121 frames, where the old behaviour emitted 120. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
| * server: one synchronized message per frame, cells and images togetherIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A frame went out as two messages: the cell diff, then the kitty placements that belong on top of it. The client wrote each to the terminal with its own write, and directly on a tty the two land in one read often enough that nobody saw the gap. Put anything that re-chunks the stream in between -- a mirror like shellglass, which reads the pty 8KB at a time and writes each read straight through -- and the gap becomes a screen refresh of its own: the cells where the picture is, repainted, with no picture on them, and the placement arriving a moment later to put it back. Every image on screen blinks once per frame, which is how it was reported: unstable, as if constantly redrawn. So the two halves travel as one message now, and the whole frame is wrapped in synchronized output (DEC private mode 2026), which a terminal buffers and presents as one update. That covers the case the single message cannot: a frame larger than one read downstream is still split, and the markers are what stop the halves being presented separately. Terminals that do not implement 2026 ignore the private mode, so it goes out unconditionally -- the same thing tmux does, and the reason a redraw under tmux never showed this. The live check asserts what the fix is: no image command reaches the client outside a synchronized update. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
* | screen: commit a wide cell's tail to prev, or the diff leaves a holeuser2026-09-03
|/ | | | | | | | | | | A wide glyph's tail column is never painted or compared, and prev never learned it either. Overwrite the head later and the terminal blanks the tail cell -- but if that column's new content matches the stale prev, the diff skips it, leaving a one-cell hole where the glyph's second half was. Seen as a missing rule cell in a pane's top border after spawning a new tab: the old tab's centered title started with a wide glyph, the new tab's border put the rule back, and the tail column stayed blank.
* pane: drop the render snapshot of panes on hidden tabs -- 154K -> 66K eachuser2026-09-03
| | | | | | | | | | | | | | | | | | | | An updated GhosttyRenderState retains a full copy of the pane's viewport (rows, cells, per-row arenas, styles) so renders can read it without the terminal lock. Measured with bpftrace and a lib-vt micro-benchmark, that copy is ~140KB for an 80x24 pane -- more than twice the terminal it mirrors (~57KB) -- and every pane kept one forever, composed or not. Only the current tab is ever composed, so app_compose now drops the snapshot of every pane on the other tabs (a pointer test once dropped), and pane_new no longer creates one at all: pane_compose and pane_select_word build it on first use. Both already begin with ghostty_render_state_update(), so a dropped snapshot is rebuilt, never missed, and the tab switch already forces a full repaint. Server memory per empty 80x24 terminal drops from 154K to 66K anon (Superlogical reports 68K on macOS; tmux 4K, zellij 1.2-6.2M). Fifty background tabs: 8.2M -> 3.8M, reclaimed on close, and cycling through tabs does not ratchet. What remains is the lib-vt terminal itself.
* pty: install the terminfo entry implicitly when it is missing everywhereuser2026-09-02
| | | | | | | | | The lookup miss now writes the embedded entry to ~/.terminfo and uses it, so a bare box gets the full TERM with no step at all. An entry that exists anywhere is respected -- possibly newer, certainly somebody's -- and a HOME that cannot be written leaves the xterm-256color fallback standing. --install-terminfo stays as the by-hand spelling; the writer moved to pty.c where the panes are.
* slosh --install-terminfo: the binary carries its own terminfo entryuser2026-09-02
| | | | | | | | | | | | The xterm-256color fallback works everywhere; the real entry is better, and a tarball user on a bare box had no easy way to get it. The entry is generated from the vendored tree (contrib/terminfo, so it describes the terminal core we actually ship), compiled with tic, embedded as bytes, and written to ~/.terminfo under every name it answers to, letter and Darwin-hashed layouts both. No root, no package, no conflict with a ghostty installed later. Proven end to end in a mount namespace: bare box falls back, installs, and the next pane says xterm-ghostty.
* pty: fall back to xterm-256color where xterm-ghostty has no terminfouser2026-09-02
| | | | | | | | | | | | TERM=xterm-ghostty was unconditional, but the entry ships with ghostty and a machine that has never seen ghostty has ncurses' database and nothing else. There, nano exits the moment it opens (C-a e flashing a pane) and a shell that cannot look up kbs guesses at backspace. The entry is now looked up the way curses would -- TERMINFO, ~/.terminfo, TERMINFO_DIRS, the system dirs, letter and Darwin-hashed layouts -- and when absent, panes get xterm-256color, which every ncurses database carries. Verified both ways with a mount namespace hiding the database.
* attach tag: just the count, in theme coloursuser2026-09-02
| | | | | | | The +x+y offset goes -- 'N clients' says the thing that matters. The inverse-video chip goes with it: the tag now draws in attach_fg / attach_bg from the theme, defaulting to gray ink on the terminal's own background. Standing information, not an announcement.
* multi-attach: say who else is watching, and where you areuser2026-09-02
| | | | | | | | | | | | A small tag in each client's own top-right corner when several terminals share the session: the viewer count, and on a view that is a crop being panned by somebody else's cursor, the crop's offset into the shared screen ("2 clients +12+4") -- which is exactly the moment a screen moving on its own needs explaining. Stamped per client after projection, because every terminal has its own count of others and its own offset; the shared status bar is composed once and can be cropped out of a panned view anyway. attach_indicator false hides it.
* multi-attach: the config knobsuser2026-09-02
| | | | | | | | | | | | | The patch made shared attach unconditional; these make it a policy. multi_attach false restores the classic displacing attach (EXIT_REPLACED, which old clients already understand). size_follows picks whose size the shared screen takes: active (the patch's rule), smallest (the tmux rule: filler, never a crop), or largest. The size fold lives in apply_canvas, so any attach, resize or departure can move the canvas under the folding policies -- including a non-active client leaving, which under smallest may have been the very client holding the floor down. A config reload re-applies both knobs to the running session.
* Add competitive multiplayer support.Ingmar Steen2026-09-02
| | | | Last input wins, canvas size is adjusted to active connection.
* shaders: remove the .so plugin loaderuser2026-09-02
| | | | | | | | | | | | The static musl release builds cannot dlopen -- musl compiles it out -- so loadable shader plugins were a feature most shipped binaries could not use. Nothing we ship used it either: every shader in contrib/shaders is an expression. Gone: the dlopen/glob loader in shader.c, shader_dir in the config, the plugin ABI table and entry macro, the Windows dl*/glob shims that existed only for it, the contrib/shader-plugin skeleton, its tests, and every doc mention. shader_abi.h stays as the shader model (cell_t, shade_ctx_t, shader_t) with a note about what it used to be.
* layout: a float sized but not positioned is centred, per axisuser2026-08-30
| | | | | | | | | | | | | | | | docs/layouts.md always said an absent coordinate takes the centred default; the loader said 0, and a layout float with only w= h= landed in the top-left corner. Now an axis the file does not name is FLOAT_CENTRE, a sentinel no real coordinate can reach, and float_clamp answers it with the middle -- every frame, at whatever size the terminal is by then, until an explicit move or resize writes a real number over it. The webdemo's welcome float is the customer: it must sit centred on a terminal whose size the layout file cannot know. The dump writes a still-centred axis by omission, because the number it happened to land on would pin to this screen's size the one intent that deliberately has none. Every mutating verb already writes back from the drawn rect, so the sentinel survives nothing it should not.
* graphics: a frame the client never got must be said againuser2026-08-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane image is re-emitted to the client as decoded RGBA, which for one screenshot is ~4MB of base64 in a single indivisible message -- and conn_send silently dropped anything that would overflow the 4MB outbox, while push_graphics ignored the refusal and the model advanced anyway. Everything stateful in that dropped frame was lost for good: the image was marked sent (so its placements drew nothing, forever), and the deletions rode out with it (so a placement the client had was never revoked). Reported as screenshots in a recovered pi transcript parked at a fixed spot on the screen, immune to scrolling and pane swaps -- those were placements whose deletion left with a dropped frame. The 1000x800 one could never be delivered at all: its transmission alone was bigger than the cap. So the frame is now transactional. Deletions queue and are repeated every flush until gfx_commit() confirms the client heard them; transmissions recorded in a frame are unwound by an undelivered commit so they are sent again. A flush nobody commits counts as undelivered at the next gfx_begin(), which is what the `graphics` control command is: bytes rendered for a CLI, not the client. The server reports the verdict and keeps the paint pending until it lands. The outbox cap now bounds the backlog rather than backlog-plus-message, so one big transmission on a drained queue always fits -- and that surfaced a second way to lose: a queued image legitimately holds the backlog over the cap until the client swallows it, and push_frame read that as a dead client and closed the connection. The session auto-detached every time a screenshot crossed the wire. A full outbox is a client that is *behind*, not gone: push_frame now marks the screen for a full repaint (the dropped diff means the client's cells are unknown) and retries as POLLOUT drains the queue; a client that truly left still shows up as a write error there. The headless suite pins the owed-until-committed contract; the new tests/live_graphics.py drives the real socket with a bigger-than-the-cap image and a reader that stalls, and would have caught both bugs: the flood must not detach, the transmission must arrive whole, and a placement scrolled away must be deleted -- then come back on scroll without a second copy of the pixels.
* windows watcher: rebuild the watch set instead of freezing itPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | watch_config() rebuilds the set of watched directories after every reload, because a config that just changed may include different files than it did a second ago. The inotify and kqueue branches unwatch and re-add. The Windows branch did neither: unwatching was a comment saying there was nothing to do, and sl_watch_add() appended to an array the pump thread had already finished reading. The pump captured `int n = w->ndirs` once, at the top, and armed one ReadDirectoryChangesW per directory it found. Everything added after that was stored and never watched. So an include added to a running session loaded fine and then sat there: editing it reloaded nothing, while editing the main config still worked, which is the shape of bug that gets reported as "sometimes". It was worse than deterministic. `w->ndirs` was written by the server thread and read by the pump with nothing between them, so whether a directory made it in depended on which won the start-up race -- _beginthreadex returns before the new thread runs, so the includes present at startup usually did, and the ones added later never could. And because nothing was ever closed, each reload re-added the same directories: handles accumulated until the cap of 8, after which sl_watch_add() failed quietly and no new directory could be watched at all for the life of the session. So the pump owns the handles and the server owns the wish. An overlapped read can only be cancelled by the thread that issued it, which settles where the boundary goes: the server writes a directory list under a lock, bumps a generation and pokes an event; the pump notices the generation moved, disarms what it had and arms what is wanted now. Cancelling waits for the cancellation to land before the slot is reused, because the OVERLAPPED and its buffer live in that slot. Duplicate directories are dropped on the way in -- a config and the theme beside it are usually in the same one -- and the compare is case-insensitive, as the filesystem is. sl_watch_close() also stopped every watcher in the process rather than the one it was given, and freed nothing. It now finds its own, joins the thread, and releases what that leaves behind. Verified on the Windows box, with a fully backslashed SLOSH_CONFIG: editing the config reloads; adding an include and then editing it reloads; repointing the include at an unrelated directory reloads; editing the file the include used to name does *not*, which is the rebuild working rather than an append; and after twelve reloads in a row it is still watching, where the old cap would have given up after eight.
* path: a directory ends at either separator on WindowsPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two places split a path by hand, both with strrchr(path, '/'), both falling back to "." when there was none: include_path() in config.c and watch_config() in server.c. On Windows "none" is the ordinary case. `SLOSH_CONFIG=C:\Users\you\slosh\config.kdl` is how a person writes it, and a path that has been near a native API comes back all backslashes whatever it started as. So an include resolved against "." -- `cannot open ./themes/t.kdl` -- and, worse and quietly, the config watcher watched "." too. Not the config's directory: the current directory of whatever started the session. Hot reload did not fail, it watched somewhere else and waited. Saving your config did nothing at all, with no error to go looking for. The default path escaped this by accident. slosh builds it as `C:\Users\you/.config/slosh/config.kdl` -- mixed, because a literal joined to $HOME always is -- and that one embedded '/' is what strrchr found. It worked for the path slosh writes and not for the one a user does. Both callers now use path_dir(), which learns to stop at a backslash too, and server.c takes path_base() for the other half rather than keeping its own copy of the arithmetic. Windows-only: a backslash is a legal character in a POSIX file name, and splitting on it there would invent a directory out of a file called `a\b`. path_dir() also keeps the separator when what is left would be a bare drive, because `C:\config.kdl` lives in `C:\` and `C:` names the current directory on drive C, which is a different place. Verified on the Windows box with a fully backslashed SLOSH_CONFIG: --check passes, the include loads, and editing the config reloads the running session. Watching a second directory is still unreliable; that is the watcher's own bug and the next commit.
* path: an absolute path does not always start with a slashPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | `include "C:/themes/t.kdl"` on Windows did not fail to find the file. It found a different one, or rather looked for it: absoluteness was tested as `path[0] == '/'`, a drive letter is not that, so the path was read as relative and joined to the including file's directory. The result was cannot open C:/Users/you/cfgA/C:/Users/you/cfgB/t.kdl which at least says what happened. A layout's `cwd="C:/srv/api"` took the same turn through path_resolve() and said nothing at all -- the pane started in the wrong directory, which is the failure this file's own header comment is about. Windows spells absolute three ways and all three now count: rooted on the current drive (`\dir`), rooted on a named one (`C:\dir`, `C:/dir`), and a UNC share. A drive letter has to be followed by a separator to qualify, because `C:file` is relative to the current directory *of that drive* -- a real thing, and not one we can resolve for anyone. Windows-only, deliberately. `C:` is an ordinary file name on POSIX and a path beginning with one is relative there, so reading it as a drive would break the platform that works in order to fix the one that does not.
* ctrl_d_exits: Ctrl-D at a prompt, where the tty cannot do itPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Windows has no VEOF. The console driver does not know the character, so ^D arrives at cmd.exe and PowerShell as an ordinary keystroke and they ignore it -- it even echoes, which is how you find out: the line reads `echo hello^D`. The one key that closes a pane on every other platform does nothing at all, and closing a pane becomes a word you have to type. That you can type `exit` instead is the tell: the intent was never ambiguous, only unspoken. So slosh speaks it. What goes to the pane is the word, not a signal and not a close from outside -- the shell runs its own cleanup, returns its own status, and keep_dead decides what is left behind, all three of which reaching past it would skip. It is also what the user would have typed, so there is nothing new to explain. The knob is what makes it honest, because a wrong guess costs a pane. It fires only with the shell itself in the foreground (a program that is running may want the EOF for itself -- `cat` reading to end is the whole job), only on the primary screen (in an editor or a pager ^D is half a page down, and the alternate screen is the pane stating that rather than us matching process names), and only on an empty line (POSIX sends EOF there; what it does not do is discard what you typed). The line is an estimate. The shell's editor is on the far side of a pty, so pane_send_key counts what it forwarded since the last Enter. Editing it does not model -- a recalled history entry, ^U, completion -- leaves the count too high, which forgets to exit. That is the direction to be wrong in; the other one throws away a half-typed command. Default true on Windows, false on POSIX, where the line discipline already does this and does it properly: ^D there is also EOF for `cat` and every REPL, none of which is ours to intercept. A knob rather than an #ifdef so the guards are the same code everywhere and can be tested where the test suite runs -- test_ctrl_d.py turns it on and drives all four decisions, plus the case that does exit. Telling trapped from forwarded needs a program that reports what it received: on POSIX a forwarded ^D also ends an idle shell, and looking for the word `exit` cannot separate them either, because bash prints `exit` itself on EOF. Verified on the Windows box as well, where it is the default: ^D at an idle cmd.exe prompt takes the pane (and with it the session, having no panes left), `more` in the foreground keeps its own EOF, and a half-typed line survives with the line still on screen.
* windows: fall back to `edit`, not to a notepad windowPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | `C-a e` on a Windows with no editor installed opened notepad, which is a GUI program: the pane it was spawned in drew nothing, so the split sat there empty while the config opened in a window somewhere else. The toast said as much, but a pane you have to be told to ignore is still the wrong answer. Windows 11 24H2 ships Microsoft Edit as `edit`, and it is a console program -- it fills the pane like nvim or nano would, so the fallback now has the same shape as the thing it is falling back from, and a first-time user gets a usable editor without installing one. It goes last of the console editors rather than first. Having fetched nvim is a statement of preference; having been shipped `edit` says nothing about what you want, so anything you chose yourself outranks it. notepad stays behind it: `edit` is not on Windows 10, or on 11 before 24H2, and something that opens is better than nothing when the alternative is an empty pane. sl_which() already tries .exe, so it is found as installed; the search still only runs when neither EDITOR nor the config asked for anything.
* windows: a native port, on ConPTY and AF_UNIXPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | slosh builds and runs on Windows 10/11, x86-64 and ARM64, as one exe with no runtime dependency beyond the OS: panes, splits, tabs, detachable sessions, the config file and its live reload, the control socket, the mouse. The port is a compatibility layer rather than a fork. compat/win holds stub <poll.h>, <sys/socket.h>, <termios.h> and friends that redirect to include/compat_win.h, which is force-included ahead of every translation unit -- so the existing sources keep their POSIX includes and their POSIX call sites, and the platform differences are answered in one place. Two decisions carry most of it. Everything the event loop waits on is a socket. On POSIX a pty master, an inotify descriptor and a unix socket are all file descriptors, so one poll() covers them; Windows has no such union, and WSAPoll waits on SOCKETs and nothing else. Rather than rewrite server.c's loop around WaitForMultipleObjects -- which caps at 64 objects and cannot express POLLOUT -- each non-socket source gets a pump thread that copies it into one end of a loopback socketpair. The loop is then the POSIX one unchanged, and only what sits behind the descriptor differs. Socket descriptors are tagged rather than guessed: a wrapped SOCKET comes back as SL_SOCK_BASE + slot, so read, write, close and poll can tell a socket from a CRT descriptor with certainty instead of inferring it from the numeric range. That is why socket() and accept() are shimmed too -- an untagged descriptor escaping into the loop is the one failure mode this scheme has, and it is worth closing completely. Three things bit hard enough to be worth naming. A pseudoconsole does not stop the child inheriting your stdio. CreateProcess copies the parent's standard handles into the child when STARTF_USESTDHANDLES is absent, and those win. With slosh's own stdout redirected -- a pipe under ssh, a file under --script, a log under the daemon -- the shell wrote its banner into slosh's output instead of into the pane, and the pane had a correct title (ConPTY emits that itself) over an empty body. The three handles are cleared for the duration of the call, leaving the child nothing to inherit. A ConPTY never reports EOF. The output pipe belongs to the pseudoconsole, not to the child, so it stays open after the child exits and pane_pump()'s read() -- the entire mechanism by which a pane learns its program is gone -- never returns 0. A pane whose shell had been told `exit` sat there looking alive forever. So the exit is waited for on the process handle, and ClosePseudoConsole is what then produces the EOF. That third thread is also why the per-pty state is reference counted: a pump blocked on a dead pipe must not be waited for, or pty_close would hang the session on the one program that misbehaves, so whoever finishes last frees. cmd.exe does not use CommandLineToArgvW's quoting. A literal quote is \" to an ordinary program and a backslash-then-end-of-quoted-section to cmd, so building `cmd /c notepad "C:\...\config.kdl"` the first way handed the editor \"C:\...\config.kdl\", backslashes and all, and notepad answered "Not a valid file name" about a path that existed. cmd /c now gets the rest of the line verbatim -- the caller composed a complete command with its own quoting, exactly as it does for sh -c. Paths are mixed, and only some programs mind. slosh joins with '/' while the environment supplies '\', so the config landed at C:\Users\you/.config/slosh/config.kdl. The CRT opens that without complaint, which is why --check reported it fine, but a GUI file dialog rejects it outright. Anything handed to another program now goes through sl_path_native() first; the internal representation is left alone. The rest is small. Panes run %ComSpec%, sessions live in %LOCALAPPDATA%\slosh rather than $XDG_RUNTIME_DIR because a session socket must not follow a roaming profile to another host, there is no SIGWINCH so a resize is noticed by comparing the console size on a bounded poll, no fork so the daemon is a fresh detached process that reconstructs its arguments on the command line, and no process group on a pty so the foreground program is the youngest descendant of the pane's shell. C-a e prefers whichever console editor is actually installed -- notepad is a GUI program, so a pane running it draws nothing and looks broken -- and says so when it has to fall back. make -f Makefile.windows ARCH=x86_64 (or aarch64) cross-compiles from a mac or Linux, and builds natively on the box with zig on PATH.
* pty: open the slave before the fork, so a pane's size reaches itv0.1.1Peter Geissler2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | On Darwin a pty master has no line discipline until its slave has been opened: every TIOCSWINSZ on the master until then fails with ENOTTY and the size is dropped without a word. A pane is born 1x1 and told how big it really is by the layout an instant after the fork -- and with the child doing the open(), that message raced the child and usually lost. Nothing ever sent it again, so the program in the pane spent its whole life believing it had one row and one column. `C-a e` hit it every time, because the editor's pane is always brand new: nvim drew its screen into two rows in the corner of a 94x15 pane. The old code knew half of this -- the child set the winsize on the slave it had just opened, which is what kept a fresh pane from being 0x0 -- but that only fixed the *spawn* size. Every resize after it still went to a master that might not have a slave yet. So open the slave here, before the fork, and let the child inherit it. The fork copies the fd table, so the slave is open from the child's first instruction and every resize the parent sends afterwards lands; BSD's forkpty() does the same thing for the same reason. The winsize is set on that slave before the fork, which orders it ahead of anything the parent sends later, and the child no longer sets it at all: by the time the child ran, the size it was spawned with could already be stale, and writing it back would undo the very resize this is about. test_pty_size.py asks the pane's own program what size it thinks it is -- for the first pane, for a pane from a split, and after a resize -- and compares it to the pane. It printed "1 1" against a 74x18 pane before this. live_m0.py asked `tput cols`, which needs a terminfo entry for xterm-ghostty and therefore fails on most macs for a reason that has nothing to do with resizing. `stty size` asks the tty itself, which is the thing under test.
* selection: drag past an edge scrolls the viewport under ituser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Text taller than the viewport was not selectable in one gesture: the drag stopped dead at the pane's border. Two faults, one visible and one waiting. The visible one: DRAG_SELECT motion subtracted the content origin in uint16_t, so a pointer above or left of the pane underflowed, the grid lookup failed, and the selection froze. The waiting one: the anchor was a pair of *viewport* coordinates re-resolved on every extend, so the moment anything scrolled mid-drag the anchor would have drifted with the viewport, re-anchoring to whatever slid under the press. The anchor is now a tracked grid ref -- the thing lib-vt documents as being for 'long-lived anchors such as selections' -- so it follows its cell across scrolling, pruning and reflow, and only the near end of the selection moves. If a program flooding output prunes the anchored row out of scrollback entirely, the selection keeps what it had; there is no cell left to anchor a new answer to. Motion clamps into the content rect (a pointer past an edge is pointing at the nearest cell), and holding past the top or bottom edge scrolls on a clock rather than on motion, because a resting pointer sends no events and 'keep going' is exactly what resting it there means. app_tick() does the step, app_next_deadline_ms() wakes the poll loop for it -- the same mechanism toasts and hover guides already use -- and both front ends call the tick, so the behaviour exists under the server and the headless harness alike. Rows per step is how far past the edge the pointer sits: one row past creeps, further hurries, so the hand already dragging is also the throttle. The metronome is select_scroll_ms, a knob so the test can configure the clock down instead of sleeping. The test drags to one row above the pane and simply holds: the viewport walks to the top of scrollback (clamping makes the endpoint exact), the copy starts at a line that was never on screen with the press, and still ends at the pressed row -- that last check is the tracked anchor's.
* a client that stops reading must not stop the sessionPeter Geissler2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | Everything the server said went out through msg_send(), which loops on sendmsg() until the kernel has taken all of it. That is a blocking write to whoever is attached. A display whose reader has paused -- C-s at the terminal, a stopped ssh, a client being swapped -- fills its socket buffer, and then the *server* sits inside that loop: every other pane, the config watcher, the control socket and every other connection stop with it. A session that is supposed to outlive its client hangs on one. macOS is where this showed up, because its socket buffers are a fraction of Linux's 64K and it wedges within a screenful rather than after a few hundred. `make test-live` could not get past the forty-connection probe in test_session.py: the server was blocked writing to a pty nobody was draining, so `slosh cmd panes` timed out and took the suite with it. So connections are non-blocking and output is queued, flushed when poll() says the socket will take more. The queue is capped: past 4MB we are not buffering a slow reader any more, we are storing a screen nobody is looking at, and the connection is dropped -- a display that comes back reattaches and gets a full repaint, which is what happens after any detach anyway. Measured, with a pane running `yes` and a display that never reads: before, the first control command hung forever; after, 40 of 40 are answered, the session stays listed, and the server's RSS is flat.
* macos: the kernel still knows, it just spells /proc differentlyuser2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Portability fixes contributed by our macOS developer, applied with one hardening tweak (below). Darwin has no /proc, so everything that read one gets the native answer: live_cwd via proc_pidinfo(PROC_PIDVNODEPATHINFO), the foreground command via sysctl(KERN_PROCARGS2) parsed back into the NUL-separated shape /proc/<pid>/cmdline gives, and SLOSH_BIN via _NSGetExecutablePath plus realpath. The pty gets its winsize set on the slave after opening it, because Darwin attaches no line discipline to the master until then and the earlier TIOCSWINSZ was silently dropped -- a pane born 0x0. The shader repl binds tab in libedit's own words, since macOS ships Python's readline as a libedit wrapper that accepts GNU syntax in silence. Two finds are cross-platform bugs: a toast wider than the screen minus the gap computed a negative x that uint16_t made ~65533, drawing the longest messages -- the ones most worth reading -- off screen; now the middle is elided (a path's tail is the half you needed) and x clamps. And a pane label was 128 bytes, which a Homebrew interpreter's full path exceeds before its arguments even start; dump-layout wrote the truncation down and a restore ran it, so it is 1024 now. Saving a layout learned cwd_as_named: the kernel answers with the physical path, and on macOS /tmp is a symlink to /private/tmp, so a layout that said cwd="/tmp" stopped round-tripping through a save. If realpath(named) is where the pane still is, the named spelling wins. Tests follow: realpath'd tmpdirs, the scroll test reads the shell's first prompt instead of spelling out bash 5.3, and wrapped pane text is joined before matching a long path. One tweak on top of the contribution: the elision needs five columns for a head, the dots and a tail -- below that `avail - 3` goes negative, and negative as size_t is a pointer into the weeds; a terminal that narrow gets a plain clip.
* compact: shared borders instead of gaps (D23)user2026-08-25
| | | | | | | | | | | | | | | | | | | | | | One knob, `compact true`: panes pack flush against 1-cell divider lines that meet in real junctions, one frame rings the tab, and a pane's title rides the line above it -- a boundary costs one cell where the default look spends four. The lines are strokes: each write says which edges of its cell it needs, unions with what is there, and never clobbers text, so ├ ┬ ┼ fall out of paint order and the focus ring recolours without inventing arms. The divider *is* the gap (eff_gap, one answer for the layout, split_fits and the resize clamp), so gap hits, hover hints and corner drags work unchanged. Interior edges give up click-to-split -- one cell cannot hold two verbs -- and a pane's status row overlays its last content row. Floats, zoom and the D6 flatten keep classic frames through place_hidden: zero new tree cases. Underneath it, mode or no mode: a border drag now moves the boundary on that side, converted on the first motion so the press that never moves keeps its old meaning -- the handle's click still splits, the rim's is still deliberately nothing (drag.rim). Which is also the fix for gap 0, where flush panes leave no gap cell to grab and the resize gesture had silently vanished with it.
* slosh: the config watcher speaks kqueue on macOSuser2026-08-24
| | | | | | | | | | | | There is no inotify to port to, so the watcher grows a second native backend: kqueue's EVFILT_VNODE, feeding the same pollable fd that server_run() already watches. Vnode events are per-inode, so each config file is watched twice -- via its directory (catching the temp-file-then-rename most editors do, and files appearing for the first time) and via the file itself (catching in-place rewrites). Either event triggers the same debounced re-read as on Linux, which re-registers the watches and so survives the rename swapping the inode out from under us.
* slosh: a pane arriving in a zoomed tab ends the zoomuser2026-08-22
| | | | | | | | | | A split, the config editor or a new float while zoomed used to land the new pane behind the pane filling the tab -- which looks exactly like the verb having failed. Whoever asked for a new pane asked to see it, so the zoom yields, in place_beside where every such arrival funnels through; the move-pane unzoom was this rule as a special case and folds in. Minimising the zoomed pane ends the zoom too, or the layout would solo a pane the strip now holds.
* slosh: refuse to attach to the session this pane is insideuser2026-08-22
| | | | | | | | | A new client displaces the current display, so `slosh` typed into a pane of "main" displaced the very client showing that pane -- you watched your own session detach, from inside it. SLOSH_SESSION names the pane's own session; that one name is refused with the way out spelled in the message. Any other name is deliberate nesting, and cmd/ls are untouched: they are how a program in a pane is supposed to reach its own session.
* slosh: paint the padding ring, so a float cannot be seen throughuser2026-08-22
| | | | | | | | A tiled pane sits on a cleared screen, so unpainted padding looked fine by accident; a float sits on whatever was composited under it, and every unpainted cell let that show through. draw_frame now fills the ring between border and contents with exactly what screen_clear leaves, and a suspended pane's never-composed content rect gets the same treatment.
* slosh: put-away panes are miniature pane framesuser2026-08-21
| | | | | | | | | | | | Brackets were tried and still read as text. Each min-bar entry is now a three-row chip wearing a real pane frame -- the same corners, because a frame is what a pane *looks like* in this program, so a shrunken one reads as exactly what it is: a pane, clickable. The put-away mark rides inside each chip and the lone legend is gone; a rung pane's bell mark keeps its own colour inside the frame; hover lights the whole chip and the whole chip is the hit. The bar costs three rows instead of one while anything is minimised (and nothing when nothing is), which is what an affordance that works is worth.
* slosh: minimized panes wear buttons in the stripuser2026-08-21
| | | | | | | | | | | A row of dim bare names read as a caption, and nobody clicks a caption. Each entry now wears square brackets -- [▬ name] -- because that is what clickable already looks like in this program: [re-run], [close], the buttons a pane's own row offers. The put-away mark rides inside each entry instead of standing at the head of the row as a legend: a legend explains the row once, a mark per entry says what each button is. A rung pane's bell mark keeps its own colour inside the brackets, and the whole bracket is the hit, as one geometry demands.
* slosh: bell_mark defaults to "[!]"user2026-08-21
| | | | | | Plain ASCII that says what it means, over a dot that had to be noticed. The mark was always a short string rather than a glyph (the emoji-width argument in config.kdl stands); the default just stops being shy.
* slosh: the scrolled edge fade earns its nameuser2026-08-20
| | | | | | | | Four rows deep at 60 a row instead of three at 45: the edge row is nearly gone (#0e0e0e from white) and the melt reads as "there is more this way" rather than as a rendering artefact. The quarter-viewport cap, the (above > 0) gate and everything else about the derivation stand; a config that writes its own scrolled chain is untouched, as ever.
* slosh: M10c, the memory -- floats survive being written downuser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dumped session quietly re-tiled its floats, which was the one data-loss gap left in D22. dump-layout now records `floating=true x= y= w= h=` -- the *wanted* rect, because intent is what survives a session and the next screen's layout clamps it like any frame -- layout files declare the same (linted: floating is a bool, x/y/w/h mean nothing without it), and a file whose every pane floats self-heals through ensure_tiled: the top float lands, the same rule closing the last tiled pane follows, no lint required. save-workspace inherits all of it from the shared dumper. `float` over the API takes x/y/w/h and then *places* rather than toggles -- a placement is a statement about where, not about whether -- floating the pane first when it is tiled, re-placing when it already floats, with omitted fields meaning "keep". The intent is stored exactly as asked: the write-back rule is for hands, whose drags need the clamp to agree with the eye; a script means what it typed. And kitty graphics respect the stack. The cell compositor gets occlusion free from paint order, but placements are sent after the diff and would paint an image straight over a float covering it. Each pane's placements are now clipped by the float rects above it -- every float for a tiled pane, the higher-raised for a float -- cropping what one clean edge can express (the pane-edge source-rectangle arithmetic aimed at another clipper, including from the top and left, where the source origin moves and the sub-cell offset is consumed) and suppressing what it cannot: a strip across the middle or a corner overlap leaves a shape one placement cannot say, and the image returns whole when the float moves. No z= needed -- clipping is the z-order, since nothing is ever allowed to overlap. D22's "still owed" paragraph is paid and rewritten; M10 is fully checked.
* slosh: OSC 8 hyperlinks pass through the compositoruser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | lib-vt tracks a hyperlink per cell and slosh dropped every one of them: the client's terminal can only offer a link it was sent, so `ls --hyperlink`, gcc diagnostics and friends silently lost theirs inside the session. The link rides beside the cell -- cell_t is the shader ABI and a link is not a colour -- as a plane of ids into an interned URI table, append-only so an id in prev_link still means the same URI next frame (which is what lets the diff compare ids instead of strings), and capped so a hostile pane cannot grow it forever. URIs with control bytes are refused outright: they are emitted verbatim inside an OSC, and a control byte in one is a way to write escape sequences into the client's terminal. The diff wraps emitted runs in OSC 8, closes what it opens before the frame ends, and treats a link-only change as a change. Chrome painted over a link takes the link with it, so a frame, modal or float across a URL is never clickable with the pane's link; links survive scrollback with the text that carries them. Plain-text URLs stay the outer terminal's own matcher, with the catch documented in panes.md: under mouse capture Ghostty only offers links with shift added to the gesture (shift+cmd hover/click), shift being its capture escape. Wrapped URLs can never be regex-matched through a multiplexer -- rows repaint as hard lines -- which is exactly what OSC 8 links, passing through whole, are for. `snapshot format:"bytes"` returns the emitter's own output for a frame (a second call is the delta): the model and the bytes are two different things, and this codebase already paid once for asserting on one while the other was wrong.
* slosh: floating panes and the drop grammaruser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane can float: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag and a rect on the leaf, exactly like `minimized` -- the seat in the tree is kept, layout skips it, the siblings absorb its share, and un-floating returns it home. The drawn rect is the wanted one clamped to the tab every frame, never written back, so a terminal squeeze round-trips exactly; an explicit move or resize *does* write back, because the hand is the author of the intent. Floating pops the pane to the centre: the jump is what says it happened. Painted after everything tiled, so the hit list -- searched backwards -- resolves an overlap to the float you can see: the z-order and the click order are the same list. It casts a shadow (float_shadow, one number), and `floating` joins the states table above `unfocused`, so a float is never dimmed as merely-unfocused. The hands: the title moves it, any other border resizes it (a bottom corner on both axes; the grabbed edge pins at walls and floors, never sliding the window), H J K L move a focused float, the =/+ key grows it about its centre, `-` shrinks it (its split was a refusal anyway), and hovering shows which edges a grab would take, from the same derivation the press uses. Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`, where it reads as search); `C-a F` opens a new floating shell -- the throwaway terminal; the function row f1..f12 became nameable in configs along the way. Guards throughout: the last tiled pane cannot float or minimize away, closing it lands the top float, splits are refused with their own words, floats are not swap targets. Dropping got a grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four edge bands meaning "insert me beside this pane, on this side" -- detach_leaf + place_beside, the surgery cross-tab moves already had. Bands are thirds (the first map of quarters showed the swap owning half of each axis), the hovered band fills the half the drop would hand over, the release resolves against the painted frame's hits so it cannot disagree with the fill, and a drop between siblings along the axis is a reorder that always fits -- refusing it was refusing the most ordinary drag there is. Also: rename-pane/rename-tab as palette actions (renaming was a double-click and nothing else, the one verb the palette could not reach), and C-u clears the rename editor like it clears the picker's query. Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
* parsers: fix seven bugs found by fuzzinguser2026-08-19
| | | | | | | | | | | | kdl: heap-buffer-overflow on trailing backslash in a string (a"\) jsonval: heap-buffer-overflow on truncated \uXXXX escape ("\u) jsonval: UB float-to-long cast on out-of-range numbers (2e22) expr: UB signed overflow in arithmetic ops — wrap as uint32 expr: INT32_MIN / -1 SIGFPE guard in OP_DIV/OP_MOD expr: stack overflow on deep nesting — add MAX_DEPTH 256 expr: 64-bit + saturation in OP_DIST to prevent squared overflow input: NULL memcpy in input_timeout's re-parse path input: signed overflow in CSI and SGR mouse parameter accumulation
* slosh: layout files are *.layout, not *.layout.kdluser2026-08-19
| | | | | | | | | | | | | | | | | | | | | The old name argued that the extension should say how to read the file. The argument lost to a collision: zellij speaks the same KDL syntax under that same extension with a different schema, and to anyone arriving from there .kdl said *whose* file it is -- naming the wrong owner is worse than not naming the parser. So a layout is x.layout, a project's is slosh.layout, the name says what the file is and --check says how it was read. (It is still KDL underneath; point your editor's highlighting at *.layout.) Nobody's checked-in workspace vanishes on upgrade: the project scan still finds slosh.layout.kdl, never writes it, and where both spellings exist the new one wins, because two files disagreeing needs one answer. The unparseable-file heuristic in --check learned the new suffix -- it matched '.layout.' with a trailing dot, which the new name no longer has. The webdemo overlay moves with it (demo.layout), rebuilt and boot-checked.
* slosh: the logo flies into place -- a particle engine, statelesslyuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every non-space glyph of the logo is a particle that flies to its cell over the first two fifths of splash_ms, then *is* the logo for the rest. There is deliberately no particle state anywhere: a position is a pure function of (target, elapsed, seed), recomputed every frame like the layout and the pane states and for the same reason -- physics that is stored can drift, physics that is derived cannot. The seed is the splash timestamp, so every attach flies differently and one splash is coherent frame to frame; a per-particle hash staggers departures, and the delay rescaling lands every straggler exactly at the end, because an almost-assembled logo reads as a bug. Five motions, picked by the clock and pinned by {"cmd":"splash", "motion":N}: rain (fall in, column by column), scatter (converge from everywhere), slide (rows enter from alternating sides), vortex (spiral in around the centre, orbit shrinking and unwinding), shuffle (the glyphs trade places, then sort themselves). The colour chains keep playing over the box throughout, so particles catch their hue as they cross into it. expr_sin() is the expression language's own sine exported for C-side animation: a motion sketched in the repl and one compiled into the binary cannot disagree about what sin(90) is. The tests learned two things the flights taught: the logo's opening run appears three times per line and assembly is staggered, so a middle occurrence can land first -- "assembled" is the row whose first block glyph is where the match begins -- and windows need margins that survive a parallel suite on a busy box.
* slosh: the splash colours glyphs, never backgroundsuser2026-08-19
| | | | | | | | | The logo is half-block art: its visible pixels *are* the foreground and the notches are the terminal's own background. A pass that painted backgrounds therefore turned the art into a slab with a logo-shaped hole -- so every splash pass is channel fg now, the effect lands on exactly the art's pixels, and the backdrop stays dark for free (a blank cell has no glyph for a colour to land on).
* slosh: the splash learns colouruser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | One tint is one colour, and every effect wore the theme accent -- six effects that were all the same dull blue. An effect is a *chain* now: phase-shifted tints in loud literal colours, mixing per cell, which is how a fixed-colour pass machinery does rainbows. Passes may light the backdrop's background too, so the box is a scene rather than tinted text, and the logo is drawn white -- the canvas that takes every hue at full saturation, where tinting blue towards pink lands on mud. The six: spectrum (rainbow bands), plasma (two interference patterns in clashing hues), fire (heat up the glyphs, embers in the box), rings (rainbow ripples), glitch (a reveal under two colours of static), aurora (green and violet curtains over cyan). The splash is deliberately the one place that does not defer to the theme: it is branding wearing a demo, not chrome. {"cmd":"splash","fx":N} picks an effect by index -- a test needs a deterministic one, and "show me the aurora again" deserved an answer. The new test counts dominant-channel hue families on the logo rows, because two hundred shades of the same blue is exactly the failure it guards against.