aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAge
...
* sl0ppty: decode the PNGs programs actually senduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Kitty graphics were green in the suite and a real program still drew nothing. libghostty-vt ships no image codec: built as a library it sets its `sys.decode_png` hook to null, and a null hook rejects every `f=100` transmission outright -- no image stored, so no placement exists, so the re-emission layer this project is mostly *about* is never reached. The program gets no error and no picture. Raw RGB worked throughout, which is why it looked done: every test here sent raw pixels, because that is what is easy to write in a test. It is the same lesson the last kitty graphics bug taught -- write the test the way a program actually behaves -- and a program with a picture on disk has a PNG. Found because ida-tui's startup logo never appeared, and its detection probe answers `OK` first, so it fails silently rather than falling back to its block art. PNG is the whole of the gap and cannot grow: the protocol has exactly three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels lib-vt already handles, and `decode_png` is the only codec hook it has. Decoding only, never encoding. The decoder is the stb_image lib-vt already vendors and we already commit -- upstream compiles that same header with the same `STBI_ONLY_PNG` for this same purpose, it is just not in the `-Demit-lib-vt` build. So nothing new is pulled in; a file we already ship is finally compiled. src/png.c is the hook, a header pre-check and a copy: dimensions are read before decoding because the buffer we return is allocated through lib-vt's limited allocator but stb's own working allocation is plain malloc and answers to nothing. Tests cover a PNG placed at the size it asked for (RGB and RGBA sources, both normalised to RGBA), the client being handed `f=32` rather than the png it was given, and a malformed png placing nothing without taking the pane with it. They fail on the previous binary.
* sl0ppty: measure chrome in columns, not charactersuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Good catch: a two-column bell mark shifted the pane title row. Everything in app.c that positions chrome -- title budgets, button placement, the hit rects that have to agree with them -- came from a `cells()` that counted codepoints, and a glyph drawn in two columns was booked as one, so every glyph after it landed a column early. Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width. That is deliberately the same table the terminal uses for pane content, so chrome and content cannot disagree about how wide something is, and it is cluster-aware: an emoji with a variation selector is two codepoints and one glyph. A wide cell claims its right half as a tail (width 0), which the renderer already knew how to skip. HANDOVER said this was a known trap and to dump the actual cells before touching arithmetic, which is exactly how this went: the first assertion I wrote passed against a deliberately broken width function, because it measured the frame's right corner -- drawn at an absolute position, so it does not move -- rather than the columns the mark claims. The test now reads the mark's own colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it fails when the width function is stubbed back to 1. Two things fell out on the way: ghostty_unicode_grapheme_width measures only the *first* cluster and returns the codepoints it consumed. My first loop ignored that and asked "did the width change when I added the next codepoint?", which concludes that an entire ASCII string is a single cluster -- the cheatsheet modal collapsed to ten columns wide with the text clipped. Use the return value. A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes against 16. Both the cell writer and the config's mark fields (now 32 bytes) clamp on a codepoint boundary, because snprintf cuts at a byte and half a codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far from the cause, not as a short mark.
* sl0ppty: watch the config directory even when it is not there yetuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | You were right. The watch is on the *directory* -- editors write a new file and rename it over the old one, so watching the file stops working the first time you save -- but a directory that does not exist cannot be watched at all. inotify_add_watch failed, the session gave up on watching for good, and writing a config later did nothing until a restart. Which is exactly the state a fresh machine is in, and exactly the state C-a e leaves you in. sl0ppty creates its own config directory at startup now, so there is always something to watch and a file appearing later is picked up. Tested from a home directory with no .config at all. Answering the other half of the question -- which settings actually reload -- turned up one that never worked at all: `shell`. main.c read $SHELL directly and nobody read the config's value, so the setting parsed, rendered in --dump-config, and did nothing. It is resolved per pane now, which fixes it and makes it live: edit it, save, and the next pane you open uses it. Everything else measured rather than assumed, and it is nearly everything: colours, geometry, the status bar and line, frame marks, shader chains and their states, and the keys including the leader itself. The two exceptions are about *when* a value is read, not reluctance -- `shell` at pane creation, and shader plugins which load on reload but never unload. Both are now written down where someone would look. One test needed rescuing from itself: it asserted the new leader worked by splitting, in panes that were by then too narrow to split. A test that fails on geometry while claiming to be about the prefix is worse than no test; it opens a tab instead.
* sl0ppty: render the config instead of shipping a copy of ituser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `C-a e` on a machine with no config opened an empty buffer, which tells you nothing about what you can set. The obvious fix -- embed config/config.kdl and write it out -- trades one problem for a worse one: a checked-in copy of the defaults is a second source of truth, and it drifts. Ours already had. Four theme colours existed in the code and not in that file, and nothing said so. So the file is generated. `sl0ppty --dump-config` renders every setting with the value it currently holds -- geometry, behaviour, all 48 theme colours, the state shader chains, and every binding including the leader-less ones -- and `C-a e` seeds a missing config with exactly that, creating the directories on the way (a fresh container has no ~/.config either). config/config.kdl stays, and is now explicitly the *prose*: what a setting is for, what it cost to get right, which ones are opinions. Values are generated, essays are written, and the file says so at the top. Three things keep the two honest: - the theme colours became one file-scope table that the parser and the renderer both walk. 48 names that used to exist in one place and be typed again in another now exist once. - the dump must parse back to itself byte-for-byte. That is what caught `bind \ split-cols` rendering unquoted -- valid-looking output that would not have survived being read back. - a test scans config.c for `kdl_child(root, "...")`, which is the parser's own vocabulary, and fails if a key is missing from the dump. Add a knob, forget to render it, and the suite tells you rather than a user. It never overwrites a config that exists: fopen(..., "wx") loses the race rather than an edit, and there is a test for that too, because it is the one thing here that must never go wrong.
* sl0ppty: C-a e opens the config, in a pane, in your editoruser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | The config watcher's whole point is the loop -- edit, save, watch the session change under you, edit again -- and the loop had a hole in the middle of it: you had to leave the session, or remember where the file lives, to start. So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than across because a config file is lines and half the height beats half the width for reading them. `editor` in the config beats $EDITOR, and vi is the fallback, being the one editor a POSIX system must have -- a wrong guess you can see and change beats a pane that opens empty. The interesting part is what kind of pane it is. Panes given a command keep their corpse (D14), which is right for a dev server and wrong for this: quit the editor and you would be left dismissing a dead pane you opened thirty seconds ago. Rather than gaming that policy by giving the pane no command -- which would be a lie in the one field dump-layout and re-run read -- panes can now be *ephemeral*: a task rather than part of the session's shape. An ephemeral pane keeps no corpse and is not written into a dumped layout, so a restart does not reopen somebody's editor on a file they had finished with. That concept is the reusable half of this commit; the keybinding is the part you notice.
* make coverage: a number, cheaplyuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `make coverage` builds a second binary with gcc's --coverage into build/cov, runs every suite against it, and prints a per-file table from gcov. About a minute. Nothing installed, nothing about the normal zig build changed. gcc rather than clang because the counters need a gcov that matches the compiler, and gcov is what this machine has -- no llvm-profdata, no llvm-cov, no lcov, no gcovr. The aggregation is forty lines of python reading gcov's own summaries, which is the whole reason this was cheap. The suites drive it through $SL0PPTY_BIN, one line in the harness, so there is no second copy of the test code and no risk of the coverage run testing something else. Two things about the result, both in the report itself: - the C unit tests link the *same* instrumented objects, so input.c, kdl.c, shader.c and expr.c are credited for the tests that exercise them. Built separately, input.c read 54% while having a dedicated decoder test -- a lie about where the tests are rather than about the code. - server.c reads ~8% and is not untested. The session server is a daemonised child ending in _exit(), which is right there and skips the flush gcov needs. The counters are lost, not the tests. Said in the output, because an unexplained low number invites someone to "fix" working code -- and changing correct code to make a tool happier is the wrong trade. Where it lands today: 85.4% of 6298 lines. The gaps that are real rather than mechanical are jsonval.c (58%), screen.c (64%) and pty.c (59%) -- error paths and the parts of the diff emitter that only a resize or a weird terminal reaches.
* sl0ppty: only panes that were given a command keep their corpseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Keeping every dead pane was too much. Split off a shell, do something in it, type `exit` -- and you are left with a corpse to dismiss, which is fussiness rather than information. You know what happened. You did it. So the policy has a line now, and the line is the *command*, not who made the pane. A pane told to run `npm run dev` is part of the session's shape: when it dies you want the error it left and the button that runs it again. A pane that is a shell is finished when you say so. Not provenance, because a shell is a shell whether a layout file made it or C-a \ did -- and that also means a session restored from dump-layout behaves like the one it was dumped from, since the dump writes `command=` only for panes that had one. The distinguishing mark turned out to need no new state: pane_label() already holds the command a layout gave it and is empty for a plain shell, so this is a policy over something already recorded. `keep_dead` is now `commands` (default), `all` or `none`, and still reads `true`/`false` as `all`/`none` -- that was the shipped spelling and a config saying it should keep working rather than quietly changing behaviour. Most of the diff is tests. The dead-pane suite had been written against the session's own pane, which is a shell under the new rule, so it was testing the half of the policy that now says "reap it". They run commanded panes from layouts instead, and the pair of cases -- a shell you split off goes, a commanded pane stays -- sits together in one place so the rule is legible in the test file too. One of those layouts wanted an escape sequence, which has to be written `\\033` so KDL's unescaping hands `\033` to the shell. Written singly the shell prints `033[38;2;...` and the test reads as "the colour stopped working".
* sl0ppty: dim the panes you are not in, and a knob to say how muchuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `dim_unfocused 60`, on by default, 0 to turn it off. The dimming itself was already possible -- `states { unfocused { dim } }` -- and deliberately not the default. The argument then was that ambient contrast is a taste, and shipping a taste as a default is how a tool earns a reputation for fighting you. That argument was mostly about the escape hatch: "turn it off" meant knowing the states block exists and what a shader chain is. A single number does not have that problem, so the objection goes away and the default can be the one most people want. The knob and the table are one mechanism -- the knob fills the unfocused chain in when the config has not -- so writing the chain by hand still wins, including writing an empty one. `states { unfocused { } }` means nothing at all, and is not quietly refilled, which needed the parser to distinguish "said nothing" from "said none". 60 by choice: white text lands on #c3c3c3, which reads as "not this one" without reading as "not available", because half the time you are reading the other pane. Picked by looking at 40, 55, 70, 90 and 120 rather than by taste alone. The tests that had to change are the interesting part. Six in test_shaders.py asserted "back to green after the drag", which is no longer what normal looks like -- and rewriting them to compare against a snapshot taken before the drag failed too, because a drag *moves focus*: press a title and that pane is yours, so the two panes legitimately swap appearances across one. They assert the rule now (the pane you are in keeps its colour, the others are pushed back), which is both what we mean and immune to the next change of defaults. test_states.py's "an unfocused pane is left alone" is inverted, with the reason written where the assertion used to be, because a test that flips should say why it flipped.
* sl0ppty: centre the middle of the status line on the rowuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | It was centred in whatever space the two ends had left over, which meant it moved whenever a pane title, a session name or a state indicator changed length. The one thing on that line you might want to glance at was never twice in the same place -- which is most of the value of having a fixed place to put it. It is centred on the row now. And when the row is too full for it *there*, it is not drawn: not squeezed, not slid along, and not written over the ends. The first attempt at this did overwrite them, which read fine on a wide terminal and, on a narrow one, ate the words "exited: status 3" -- caught by a test for the dead-pane indicator rather than by anything looking at the status line, which is the good kind of accident. The rule that came out of it is the honest one: the ends say which session and pane you are in and are not written anywhere else on screen, while the middle is a hint you can get back by hovering or a version you can read a moment later. So the middle is the part that disappears. It keeps a blank column on each side while it is there, because touching reads as overlapping.
* sl0ppty: say which build you are talking touser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | The middle of the status line is empty unless the pointer is on something, which is most of the time. It says which sl0ppty this is now. That reads as decoration and is not. A session keeps the binary it started with, so after a rebuild there are two versions in play: the one on disk and the one you are attached to. This session has been dogfooding sl0ppty for a while, and "which build is this?" has already been the answer to two bugs that looked like something else -- once when a config reload appeared to do nothing, and once when kitty graphics were fixed and still broken on screen. The banner answers it without asking, and `sl0ppty --version` answers for the binary, from the same string. The version is baked in by the Makefile from `git describe --always --dirty`, into a generated header that is only rewritten when the value changes -- so a commit rebuilds and a second `make` does nothing. It takes the hint's slot and yields to it: a hint is about what you are doing right now, the banner is about always, and it comes back the moment the pointer moves off. Both give way to the ends of the line, which was already the rule for hints and is why a narrow terminal loses the middle rather than its session name. `version_banner false` for anyone who finds it noisy; it is a separate knob from `hints`, and the tests hold both directions of that.
* sl0ppty: write the session back out as a layout, and a loop that uses ituser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Dogfooding this while building it has one sharp edge: a running session keeps the binary it started with. `reload` re-reads the config, it cannot re-read the code, so every build you want to try means losing your screen. The only thing worth carrying across a restart is the shape, and sl0ppty already knows how to read one -- it just could not write one. `dump-layout` is the inverse of `--layout`: tabs with their names and purposes, how the panes are split, in what proportion, in which directory, running what, which tab was active and which pane had focus. Two details make it worth trusting. The directory is where the shell *is*, read from /proc, not where the pane was started -- after any amount of `cd` those differ and only one of them is useful. And a pane running the session's default shell is dumped with no `command`, so it comes back as a shell rather than as a shell wrapped in a shell, one layer deeper every restart. contrib/sl0ppty-dev is the loop: attach, and when the session ends because you asked for a restart, come straight back with the layout. It is two halves because it has to be -- `restart` runs inside the session, and the moment the session ends so does the shell it was typed in, so it leaves the layout and a note and the loop outside picks them up. What does not come back is what was running *inside* a program. A shell returns as a shell, in the right directory, with an empty history. That is the limit of restoring a layout rather than a session, and the script says so rather than implying otherwise. Three bugs found by the round-trip test, all the same kind -- a dump that loads without complaint and rebuilds something subtly different: - `split=` belongs on the node that *has* the children, not on the children. Written the other way it parses fine and gives the wrong tree. - a split's weight was never restored, because the edit meant to read it silently matched nothing and was never applied. - `active=true` landed on the wrong tab: the index is recorded while the new tabs sit after the old ones, and `replace` then moves them to the front. Which is why the test asserts that a dump of a reload is byte-identical to the first dump. Anything the dumper loses shows up there at once, where reading it and nodding would not.
* sl0ppty: let the cheatsheet breatheuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | A blank line above every section heading, so the sheet reads as five short lists instead of one wall of thirty rows. Not above the first: the "C-a then:" caption already separates that one, and two blank lines in a row is the same mistake in the other direction. The blank is a row like any other, which keeps the two-column arithmetic honest -- it takes up space, it just draws nothing. Two consequences that had to be handled rather than discovered later: - the column fold looks for a heading, and would happily land on the blank *above* one, starting the second column with an empty line that reads as a bug. It lands on the heading itself now. - that leaves the stranded blank at the bottom of the first column, which made the box a line taller than its contents. Trailing blanks are not counted when sizing, so the footer sits one line under the last row of keys, whether the sheet is one column or two. Both are asserted, because both are the kind of thing that looks fine in the size you happened to test at.
* sl0ppty: bindings that need no leaderuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every binding needed the prefix first, which is the safe design and the reason a leader exists: a multiplexer that takes chords away from the programs inside it is a multiplexer you fight. But it is the user's keyboard. Somebody who wants ctrl+alt+arrow to move between panes should be allowed to have it, including the part where that chord stops reaching vim. keys { direct { bind "ctrl+alt+left" "focus-left" bind "ctrl+alt+right" "focus-right" } } A block rather than a flag on each line, because these take a chord away from every program in every pane, and that is worth seeing at a glance -- and worth having somewhere to write the warning down, which the config now does in full: prefer chords no program wants, a bare letter certainly is not one, `none` gives it back. Three ordering decisions hold it together, none of them obvious from reading the code, all three tested: - the direct lookup happens *last* in the key path -- after the rename editor, the finder, the cheatsheet, and the prefix itself. An overlay still owns the keyboard, and a direct binding cannot shadow the leader, which would make the leader unusable with no way back. There is a test that tries exactly that and finds the prefix still working. - `direct` is part of a binding's identity rather than a property of it. `x` after the leader and `x` on its own are two bindings; binding one must not silently redefine the other. - the after-the-leader lookup falls through to direct bindings, so pressing the prefix first never makes a binding stop working. A rule you have to keep track of is a rule that will catch you out. The cheatsheet lists them under "without the leader", apart from the rest, because the caption at the top of it says "<prefix> then:" and these are precisely the ones that is not true of. The tests state the bargain rather than just the mechanism: the chord fires with no leader, and what it fires on, the program never sees -- asserted with `cat -v`, so a keystroke arriving in the pane is visible on screen rather than an invisible control byte that would prove nothing either way.
* sl0ppty: the prefix badge names the key you actually bounduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | The leader has been configurable since M6 -- `keys { prefix "ctrl+b" }` -- and one place had not been told. The badge in the status bar, the indicator whose entire job is to say "you are in prefix mode", was the literal string "C-a" whatever you had configured. That is worse than not having the setting. A prefix you can change and a UI that keeps naming the old one teaches you to distrust the thing telling you which mode you are in, and it is precisely the person who rebound it -- the one who wanted C-a back for start-of-line -- who gets lied to. It renders the binding now, through the same function the cheatsheet uses, so the two cannot disagree. Measured rather than assumed to be three columns wide, because `C-space` is seven and `M-\`` is three of a different width. tests/test_prefix.py holds the whole story in one place: four prefixes, each shown correctly in the badge; the rebound key running commands; the *old* key going through to the program untouched (checked with `cat -v`, so a control byte arriving is visible as ^A rather than an invisible character that would prove nothing); the double-press still sending the key itself; and the cheatsheet agreeing with the badge. Also said it out loud in the README and the config, since the way this came up was somebody reasonably assuming it was not possible.
* sl0ppty: modals get their own surface, and dim what is behind themuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things wrong with the first one, and both are about it being a modal rather than a pane that happens to float. The close button was invisible. It was drawn in the pane button colour on the finder background, and in the default theme those are the same value -- #45454a on #45454a -- so it only appeared when the pointer landed on it and the hover colour took over. That is the hazard of borrowing names from another surface: each one is right, and together they are nothing. A modal is its own surface now, with its own names (modal_bg, modal_fg, modal_border, modal_title, modal_button, modal_button_hover), a dark panel a shade lighter than the dimmed screen, a border in the accent, and a button chosen to be visible *without* the pointer on it. All six themes set them. And nothing behind it moved, so it read as another pane rather than as something in front. There is a scrim now, `modal_scrim` (0..255, 120 by default), and the pleasing part is that it is not a new mechanism: it is the shader pass aimed at the screen instead of at a pane. Same dim, same materialisation of default colours, no special case. It goes down after everything else is painted and before the modal, so the modal is the only thing at full brightness -- which is the whole idea, and is asserted rather than assumed. The shape is now reusable: modal_frame() draws the box, title, close button and hit, and returns the rect inside it; draw_scrim() is one line above it. The next modal is a function call and looks the same as this one. The finder is deliberately left alone. It is a picker rather than a modal -- it navigates, where a modal here dismisses on any key -- so unifying them would be making two things one because they are both rectangles.
* sl0ppty: C-a ? shows every binding you haveuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A prefix-key multiplexer is unusable until you know its keys, and the only place they were written down was a config file and a README. Now they are one keystroke away, in a pane-shaped modal floating over the layout. It is built from CFG.binds, not from a list of what the defaults are. Rebind something and the sheet says what you bound; unbind it (`bind "-" "none"`) and it stops advertising it; change the prefix and the "C-a then:" line at the top changes with it. A cheatsheet that can disagree with the keyboard is worse than no cheatsheet, and it would disagree the first time anybody edited a config -- so it cannot be a list. Bindings that share an action are merged onto one row: `h ←` is one thing to learn, not two, and the nine tab digits are `1…9` rather than nine rows. Keys are named the way you press them -- `\` and `?` and `H`, not "backslash" and "S-slash" and "shift+h" -- and both spellings still parse as config, so the sheet stays copyable. The frame is a pane's frame, corners and title and close button, because a dialog should wear the chrome of the program it belongs to. It fills its own background: a modal you can read a shell prompt through is one nobody trusts. Two columns when there is room, one when there is not, split at a group boundary so a heading never ends a column alone, and every string clipped at the frame -- a small terminal loses words rather than its border. Any key dismisses it, and that key does nothing else: dismissing a modal must not also run the thing you happened to press. A click anywhere does the same and does not reach what it landed on. `?` is bound twice, to shift+slash and to slash, because whether it arrives with a modifier depends on the outer terminal: as a plain byte there is no shift to be had, and under the kitty keyboard protocol -- which our own client asks for -- there is. Binding one of them is a binding that works on the machine it was written on.
* sl0ppty: colour the states that mean a pane is not liveuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The shader machinery has been able to do this since it existed, and shipped an opinion about exactly one state. Now it ships three, and the line between those and the rest is the point: a pane that is *not live* gets a default -- dead, suspended, scrolled. In all three the cells are showing something other than a running program's present: output from something that has exited, a pane that never started, or the past. None of that is discoverable by looking unless something says so, which is the whole argument for the feature. a pane that is merely *not the one you are in* does not -- unfocused, and one being dragged. That is ambient contrast, which is a taste, and a taste shipped as a default is how a tool gets a reputation for fighting you. It is one line of config away for anyone who disagrees, and there is now a test asserting we have not quietly changed our minds. So: suspended is greyed and dimmed (inert, still legible -- its contents are the command it is waiting to run), and scrollback takes a 9% wash of the scrollback indicator's own colour, so a theme moves the indicator and the wash together. Checked against phosphor, amber and paper: each gets its own hue rather than a hardcoded one. The other half is the gap this leaves. A collapsed header never reaches the shader pass -- a header is chrome and shaders colour contents (D13) -- so collapsing a tab used to hide precisely the facts the colour exists to show. The header now carries them in words, in the same order the status line ranks them: `exited: status 3`, `not started`, `▲ 12`. tests/test_states.py asserts the line in both directions, including that an unfocused pane is left exactly as it was, and that a flattened tab still says which of its panes never started.
* contrib: the shadertoy presets, usable in sl0pptyuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | They only existed in a browser, which is a strange place for a terminal multiplexer's shaders to live. Now each one is a file you can paste. contrib/shaders/ holds all thirty-two, one per file, *generated* from the presets in shadertoy.html by contrib/gen-shaders. The page stays the source: it is where they are written, previewed and conformance-tested against the compiler, and a hand-kept second copy would drift the day after it was written. `gen-shaders --check` says whether the files are current, which is a test, so the drift is a failing build rather than a stale example. contrib/shader-tour cycles a session through them, in the shape theme-tour already established — write the config the session watches and let the watcher do the rest. It takes one shader, one group, or nothing for all of them, and it resolves a short name (`torch`) as well as the whole slug (`guides-torch`), because nobody remembers which group a shader is in. The tour writes the theme alongside the shader: shaders resolve default-coloured text through theme's default_fg/default_bg, so a preset judged without one is being judged against a guess. The new test is the one that matters. Every shipped file is loaded into a real session and has to *change the cells of a pane* -- an example that previews beautifully and does nothing when pasted is worse than no example, and neither the toy's conformance test nor a config parse would notice. Three of them are then checked for the specific thing they claim: scanlines alternate row by row, indent guides land on every fourth column, a flat dim is flat. One session, reloaded per preset, which is both quick and a fair exercise of the path anyone will actually use. Renamed the "the built-ins, as expressions" group to drop the qualifier from its filenames; it was generating built-ins-as-expressions-scanlines.kdl, which is a sentence rather than a name.
* contrib: presets worth stealing, and a test that they do anythinguser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Eleven examples, grouped, and most of them are less "effect" than *instrument*: a cursor line, a crosshair, indent guides, a right margin at three quarters, a torch that dims everything more than eight cells from where you are looking. Things that tell you where you are, inside a program that has never heard of any of them -- which is the argument for shading contents rather than chrome, made in examples instead of prose. Then the fun: phosphor and amber CRTs (grayscale, tint, scanlines, vignette as a four-pass chain), film grain, a rolling bar, an interlace flicker; sonar pings expanding from the cursor, ripples, a barber pole, plasma from two triangle waves, matrix rain with a fading tail; and every built-in written as an expression, because `vignette` being `dim` with an argument is the whole design and is better shown than argued. Two rules the presets follow, both about staying identical to the C: never multiply `t` (it is a millisecond clock and gets large -- divide it down first and the arithmetic stays in an int32 forever), and keep hash coefficients small for the same reason. `(x * 11) % 23` is fine; the usual Knuth constant is a different number in C than in JavaScript. The test now lifts the *presets* out of the page as well as the evaluator, so one added later is conformance-checked without anyone remembering to add it. And it evaluates every preset over a whole pane at four points in time and fails any that is zero everywhere -- an example that does nothing reads as a broken shader system rather than a bad example. That check caught its own first draft: sampling every other row made three `y % 2` effects look dead when they were fine. It samples every cell now, which costs six hundredths of a second for four hundred thousand evaluations, because the thing being tested is quick.
* contrib: draw the shadertoy preview at the size it meansuser2026-08-14
| | | | | | | | | | | | | | | | | The canvas was 576x432 with `max-width: 100%`, so a wide pane stretched a 576-pixel preview across it and `image-rendering: pixelated` made sure you could see every one of those pixels doing it. A preview of a terminal that does not look like a terminal is not much of a preview. Two sizes now, deliberately: the backing store is device pixels (DPR, capped at 3) so glyphs stay sharp on a HiDPI screen, and the CSS size is pinned to the real 576x432 so nothing ever scales it up. A viewport with room to spare gets space around the preview instead of a blur; one that is genuinely too small still shrinks it, smoothly, which is the direction where scaling is fine. Checked by rendering a frame against a stub canvas under node: the draw path covers exactly the CSS box while the backing store is twice that at DPR 2.
* contrib: a shadertoy for shader expressionsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | You cannot see an expression by reading it. `(y % 2) * 40` is scanlines and `(x > cols - 10) * 120` is a right margin, and neither is obvious until it is on a screen -- so here is a screen. One HTML file, no build and no server: open it and it works, offline, including from a checkout on a machine that has never run sl0ppty. It draws a fake pane with plausible contents, applies a chain of passes to every cell, and prints the config line to paste. The mouse moves the cursor, so `dist(x, y, curx, cury)` behaves the way it will in a pane rather than the way it does at coordinates you imagined. Anything reading `t` animates. The presets are every built-in written as an expression, which is the equivalence the whole design rests on: `vignette` and `spotlight` are not special, they are `dim` with an argument. It also reports what each expression *reads* and whether that makes it cacheable, because the dependency analysis is the interesting half of the implementation and is otherwise invisible. The hazard is that this is a second implementation of the language, in another language, and it would disagree with the compiler quietly -- in the third digit of a colour, on one preset, at one cursor position. So tests/test_shadertoy.py lifts the evaluator straight out of the page (not a copy of it, the page itself) and runs a few hundred expression/environment pairs through both it and src/expr.c, including the corners: division by zero, the ternary's untaken branch, `dist` counting a row double, and six expressions that both sides must refuse. build/expr_eval is the real compiler as a filter, which is what makes that comparison possible at all. It found a bug on its first run, in the page: unary `-` and `!` did not thread the environment through their closures, so `!x` threw instead of evaluating. Which is the argument for the test, made better than I could have made it. Skipped with a message when node is missing -- a checker for a contrib toy is not a reason the suite cannot run.
* sl0ppty: the graphics stream gives the cursor backuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "the cursor is not rendered in zsh" in a newly spawned pane. It was not about zsh, or about that pane: it was about the pane next to it, which was animating an image. Placing an image means parking the cursor on its target cell first, and the graphics bytes go out *after* the cell diff -- deliberately, so a repainted cell cannot land on top of a placement. That is also after the frame has put the real cursor where the focused pane wants it. So with any image on screen, the last instruction the terminal received every frame was "go to wherever that image is", and the cursor sat there: in another pane, moving about with the picture. With a bouncing DVD logo next door it is quite a sight. gfx_begin() now opens the stream with DECSC and gfx_flush() closes it with DECRC, so the whole thing is cursor-neutral no matter what it contains. The save has to be written at the start of the frame rather than at flush time, because transmissions are emitted as images are recorded and are already in the buffer by then. A frame with nothing to say drops both and still emits not one byte, which the test asserts, because a save/restore pair on every idle frame is exactly the sort of cost that creeps in unnoticed. Two tests: that the stream is bracketed (and that it really does move the cursor, or the assertion proves nothing), and that an idle frame is empty. One C trap worth the comment it now has: "\x1b7" is a *single* hex escape, 0x1b7, not ESC followed by 7. The compiler catches that one because it is out of range -- and would not catch "\x1b1", which compiles quietly into one wrong byte. Escape then digit is always two strings.
* sl0ppty: re-emit what the program meant, not what it coversuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Images rendered after the last commit, and were still wrong twice. Both mistakes are invisible in a still picture, which is what every test was looking at, and obvious the moment anything moves. **The sub-cell offset was dropped.** A placement says where the image starts *inside* its first cell (`X=`/`Y=`); that is what makes motion smooth rather than a slideshow. We tracked the placement and re-emitted it without those, so a bouncing logo moved in steps of a whole cell -- eight pixels across, sixteen down. **`c=`/`r=` were passed on unconditionally.** They mean *scale the image into this many cells*, and the count a natural-size image happens to cover goes up by one whenever it straddles one more boundary -- so the picture changed size as it slid along. They are now sent only when the program asked for them, which lib-vt reports as 0 when it did not. Cropping splits the same way, and getting it wrong took a test failure to notice: at natural size a lost cell costs the screen pixels the pane has left (measured from the offset, not the cell edge, or an offset image hangs up to a cell over the border); when the program asked to scale, a lost cell is worth source/cells pixels and the crop is proportional. Cropping a 4-pixel image scaled across 40 cells by screen pixels removes all of it. gfx_place() took twenty positional arguments, half of them uint32_t pixel quantities where a transposed pair compiles perfectly and lands the picture slightly wrong -- the exact bug class this file exists for. It takes a struct now. `{"cmd":"graphics","format":"bytes"}` answers with the escape sequences the client is actually sent. The model being right is not the same as the bytes being right, and this whole episode was a lesson in asserting on the first while the second was wrong. The new tests assert on the bytes.
* sl0ppty: carry the terminal's cell size, so images can have a sizeuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | "Kitty graphics pass through" was true of the tests and not of anything else. Every test transmitted and placed in one command with explicit c=/r=; real programs upload once with `a=t` and place per frame with `a=p`, saying nothing about cells, and that path was broken from end to end. A placement that does not say how many cells it covers is sized from the image's pixels and the cell size. We never had a cell size: - pty_spawn and pty_resize left ws_xpixel/ws_ypixel at zero, so a program asking its own tty how big a cell is got nothing and fell back to guessing; - ghostty_terminal_resize was called with 0, 0 for the cell size, so lib-vt computed zero cells for such a placement -- and a placement covering zero cells is skipped, which on screen is an image that simply is not there. So the size now runs the whole way through. The client reads it from TIOCGWINSZ (re-read on every resize, because a window can move to a monitor with different metrics), MSG_HELLO and MSG_RESIZE carry it -- a 4-byte payload still means "I did not say", so an older client still attaches -- the app hands it to every pane in every tab, and each pane gives it to both its pty and its terminal. Where nobody knows, the default is 8x16 rather than 0. That is the whole lesson of this bug: a plausible cell size means an image appears at roughly the right size, and a zero means it does not appear at all. `{"cmd":"resize"}` takes optional cell_w/cell_h, so a script can be a client that knows its own metrics, and so the tests can be one. The tests now use the protocol the way programs use it, including a check that a program can read real pixel dimensions out of its own pty -- the thing that was silently zero. The remaining half of the fix is one layer down, in vendor/patches/0001.
* vendor: keep kitty images across a screen clearuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | libghostty-vt's eraseDisplay called kitty_images.delete(.{ .all = true }) for ED 2 and ED 3, where the `true` also frees the image *data* of anything no longer placed. A full-screen program that clears and redraws therefore loses every image it uploaded, and every later placement of that id draws nothing. That breaks the half of the protocol worth having. Kitty separates transmitting an image (`a=t`, stored under an id) from placing it (`a=p`, put on screen) precisely so a program can upload once and place many times -- which is also why the delete command distinguishes `d=a` (placements) from `d=A` (placements and data). A screen clear should be the former. The patch removes only the freeing: placements on the cleared screen still go, and the image store still bounds itself with its 320MB limit and LRU eviction on insert -- the mechanism that makes keeping them safe, and that would be pointless if a clear dropped everything anyway. Found with a bouncing-DVD screensaver that uploads ten frames up front and places one per frame. It works in kitty; under us it drew nothing. D12 said "with a patches file if we ever need one". vendor/patches is that file, with the reasoning and what a re-vendor has to re-apply; the vendor json lists it. Worth sending upstream.
* sl0ppty: a shader's strength can be an expressionuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adding an effect meant writing C -- in the binary, or since D15 in a shared library. Both are a lot of ceremony for "dim every other row". So: `amount` may be an expression, compiled to bytecode when the config is read and evaluated per cell. shaders { dim amount="(y % 2) * 40" // scanlines dim amount="(x > cols - 10) * 120" // a right margin tint amount="255 - dist(x, y, curx, cury) * 12" color="#ff5fd7" } The scope is the argument, and that is the whole design. Every built-in here is already colour_op(amount) with the amount a pure function of position -- vignette, zebra, ruler, margin and spotlight differ only in that function -- so the part worth handing to a config is the *number*, not the colour maths. Three things follow at once: the mixing stays in C where it is fast and correct; an expression cannot produce an invalid cell, because it produces one byte; and that byte is cacheable. The cache is what makes an interpreter affordable, and the numbers are why this exists at all. Measured on a 200x50 pane, one pass: native vignette 6.1 ns/cell constant expression, folded 5.6 ns/cell positional expression, mapped 5.2 ns/cell animated (reads the clock) 35.0 ns/cell A shader you wrote in your config costs what a compiled one does. A program that does not read the clock is evaluated once per cell into a byte map and reused until something it reads changes; one that does read the clock is interpreted per cell, per frame, which is the honest price of animation and is opt-in. (The earlier measurement of 56ns for interpretation was per evaluation -- correct, and the reason the map exists rather than an argument against the language.) What a program reads is derived by the compiler rather than declared, and that dependency set *is* the cache key. A stale map is therefore unrepresentable rather than merely unlikely -- the same rule the layout follows, arrived at from the other direction. A unit test caught the first violation of it before anything else did: `cols` and `rows` carried no dependency flag, on the theory that the key always contains the rect, and so a program reading only the size looked constant and was folded at compile time, where every size is zero. The language is deliberately total. No loops, no recursion, no jumps: even `?:` compiles to evaluating both sides and selecting, because nothing in it has an effect. Evaluation is O(program length), so a config cannot spin, and there is nothing to sandbox. Division by zero is 0 rather than a trap: an expression that is wrong should look wrong, not take the session with it. One that does not compile drops that one shader with a warning -- not the config, and not the half-strength version of what you asked for, which is what the number path did with garbage and is now also fixed. shader_t gained the compiled program, so the plugin ABI goes to 2. A plugin built against 1 is refused with "shader ABI 1, we speak 2" and needs nothing but a rebuild -- the check earning its keep the first time it was asked to.
* sl0ppty: a tab is something you can name and moveuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tabs had names -- a layout could set one, the control API could set one -- and no way to type one. A tab you made yourself was stuck being a number. And the strip's order was the order tabs happened to be created in, forever. Both are the same gesture as the pane equivalent, on the other thing that has a name: double-click a tab rename it in place, in its own cell in the strip drag a tab move it along the strip They land together because they share the machinery they are built on, and splitting them would have meant writing the shared half twice. The rename editor is the pane's, generalised from "renaming a pane" to "renaming a pane or a tab" -- one editor with a subject rather than two that would drift. Enter keeps, Escape abandons, clicking away commits, empty gives the tab back its number. It is seeded with the name only: a pane seeds from its title because a title is always something, but a tab shows its *number* until it is named, and offering "3" to backspace over would be offering to edit the wrong thing. The drag is the existing drag machine's third verb, and it reorders **as you go**. The strip is the only thing that could show an insertion point, and a strip already showing the result needs no such invention -- no ghost tab, no caret, nothing remembered between frames. Dragging off the strip is not a cancel: it is simply not a place to put a tab. Two things that were not obvious until they were written down: - pane ids and tab ids are separate sequences, so pane 2 and tab 2 both exist. The double-click detector remembered only the id, so a click on one followed by a click on the other read as a double-click on whichever came second. It remembers the kind too now, and the test asserts the ids really do collide before asserting they are told apart -- otherwise it would quietly stop testing anything the day the sequences diverge. - `cur` is a *position*, not an identity, so a move has to recompute it. Carried across, you would be looking at whichever tab slid into the index you used to be at. `{"cmd":"move-tab","id":N,"index":M}` does the same thing from a script, and is what tests the arithmetic from a tab that is not the one being moved -- the case a drag can never produce, because a drag selects what it grabs.
* sl0ppty: the new-tab button is a bare +, and yours to chooseuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | It read "+tab" because of this, in the code, right above it: Not a bare "+": the frame already has one, for splitting a pane. Two verbs that look identical is a UI bug the fork shipped and noticed. That was true when it was written and has not been since f2f3d67, which removed the frame's "+" when the border became the button. The collision it was avoiding no longer exists, and the word was left explaining itself to nobody -- while every other affordance in this UI is a bare mark: _ # x on a frame, and a hint under the pointer for anyone who wants the word. So: "+", padded to three cells, because their own comment applies -- a one-cell target is a thing you miss with a mouse. Also configurable, as `newtab_mark`, which it should have been already: it was the only mark here that was not (zoom_mark, zoom_on_mark, close_mark, min_mark, bell_mark all are). Try "[+]" or "new" if you disagree; a session picks it up on save. Four tests were asserting the literal "+tab". Three of them only needed the new string, but two were using it to *locate* the button -- bar.index("+tab") -- which is exactly the mistake of asserting something incidental: what the button says is now config, and a test that spells it out fails the day somebody changes their mind. They look it up through the hit list now, which is what they meant. test_no_more_plus keeps the invariant that makes the bare mark safe, and now says so: there is exactly one "+" in the UI, so it means one thing. If one ever returns to a frame, that button needs its word back.
* sl0ppty: shaders can be loaded from shared librariesuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adding an effect meant editing shader.c and rebuilding, which is fine for us and useless to anyone else. Now any `*.so` in the shader directory that exports a plugin table is loaded at startup, and what it declares is named in the config exactly like a built-in -- same syntax, same three parameters, same pass. Default location is a `shaders` directory beside the config file, so dropping the .so next to config.kdl is the whole installation. The interesting part is what a plugin can see, which is one header. shader_abi.h holds cell_t, color_t, shade_ctx_t, shader_t and nothing else: no libghostty-vt, no pty, no layout, no session. A shader is therefore unable to reach anything but the cell it was handed, not by rule but because nothing else is declared where it can find it. cell_t and color_t moved there from sl0ppty.h for the same reason -- that is the one place they are a contract with code compiled outside this tree rather than an internal type. Loading is deliberately narrow: - refused unless the library reports our ABI version *and* matching struct sizes. The version says which contract; the sizes catch someone editing their copy of the header, which a version number alone calls fine and a running session calls garbled colours; - refused if it redefines an existing name, because a config naming `dim` means the `dim` it was written against; - all-or-nothing per file, and per file: a bad library is a line on stderr and everything else still loads (D9 fail-open); - nothing is ever unloaded. A shade_fn may be sitting in a live config or on a pane, so dlclose() is a jump into an unmapped page. A reload picks up newly *added* plugins; replacing one needs a new session. This is the single exception to D9's "a plugin is a subprocess", and it is argued in D15 rather than drifted into: a shader is called per cell, per pass, per frame, which no subprocess can be in the path of, and the measured alternative -- interpreting bytecode per cell -- is 56ns against 8ns for the compiled function. On trust, `shell` and a layout's `command=` already let a file in the config directory run anything as you, so a .so beside them is not a new boundary. The docs say so plainly rather than implying a sandbox. contrib/shader-plugin is a working skeleton: Makefile, two commented effects (one positional, one animated), and the three rules a shader has to keep. It is built and loaded by the test suite, so a broken example is a failing test rather than someone else's afternoon.
* sl0ppty: expand ~ in a path that a config file wroteuser2026-08-14
| | | | | | | | | | | | | | | | | `cwd="~/dev/api"` is in the README, in config/layout.example.kdl, and did nothing. `~` is a shell thing, and no shell has touched a path that arrives from a layout file -- so it went to chdir() literally, failed, and left the pane in whatever directory the session happened to be started from. The pane still ran, so nothing looked wrong until you typed `ls`. path_expand() handles `~` and `~/...` against $HOME and returns everything else untouched, including `~user`, which needs the password database and is not worth linking it in for -- returned unchanged rather than half-expanded into something wrong. Applied where a layout's cwd is read, which is one place because a pane inherits its tab's. Found while documenting a shader directory that was about to have exactly the same bug.
* sl0ppty: walk the row, not screen_at()user2026-08-14
| | | | | | | | | | | | | | | | | | | | shade_apply() reached every cell through screen_at(x, y), which re-derives the row base and bounds-checks a coordinate the loop already guarantees -- once per cell, per shader, per frame. Clipping the rect to the screen once, up front, makes the pointer arithmetic provably in range, so the inner loop can walk a row. Same output, and the clip is not a behaviour change: the layout never asks for a rect that needs it, and a caller that did was getting NULL-skipped cells before. Measured with .scratch/shprof.c on a 200x50 pane and a three-pass chain: 8.0 -> 6.1 ns per cell-pass, 0.24 -> 0.18 ms per frame. That profile also says where the time is *not*: `vignette`, which computes squared distances and a divide, is cheaper than `dim`, which computes nothing. The arithmetic in these shaders is free; what costs is per-cell overhead and the colour mixing. Anyone arriving here to precompute a per-cell amount table should read the numbers first -- it buys nothing.
* sl0ppty: a README for people, not for the person who wrote ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | The old one was an essay. It explained the reasoning behind the split guide, the budgeting rule shared by the tab strip and the status line, and the exact mechanism by which images are re-emitted -- all true, all in DESIGN.md, and none of it what somebody deciding whether to run this wants to read. Rewritten as: what it is, why it exists, how to install it, the keys, what the mouse does, what makes it different, how to configure it, how to script it. Half the length, no design commentary, links out for the rest. The "why" was also wrong. It said this exists because agent sessions want programmable chrome. That is a thing it does, not the reason it was written. The reason is that the existing multiplexers are crusty in ways that follow from their architecture -- stored layout state, mouse handling that duplicates the drawing, extension models bolted onto a core that never expected one -- and a fork carrying sixty-odd patches was spending its effort arguing with that rather than multiplexing. Hence starting over, on top of Mitchell Hashimoto's terminal core rather than pretending that part is easy.
* sl0ppty: a pane outlives the program that was in ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane whose program exited was reaped before the next paint. The pane went, and with it everything the program had printed -- including, always, the one line that would have explained why it stopped. Close the last one and the session went too, so a mistyped command in a fresh session closed the terminal you were typing in. Now the pane stays. It keeps its contents, writes one line into its own backlog saying how it ended -- `[process exited: status 3]`, `signal 9` -- says the same thing in its frame and in the status line, and offers the two verbs that still mean anything: ╰ exited: status 3 ──────────────────────────────[re-run]─[close]─╯ `[re-run]` runs the same argv in the same cwd in the *same pane*: same id, same node in the tree, same terminal. Nothing that referred to that pane has to be told anything, and the run that ended stays above the new one in the scrollback, which is what makes comparing two runs possible at all. C-a r and {"cmd":"rerun"} do the same thing; a live pane is refused rather than restarted, because "run it again" is not a request to kill something. Those buttons are the OSC 5577 row, not a second mechanism. A dead pane's own buttons are inert -- a click would write a report into a closed pty -- so the row is given over rather than added to. `close` is listed last, so it is drawn rightmost and is the one that survives a narrow frame: being unable to dismiss a dead pane is a trap, being unable to re-run one is an inconvenience. `dead` is a shader state now, which D13 said it could never be: it named a frame that could not be drawn, and now there is one. Dead panes drain to grey by default, so a wall of them reads at a glance. A collapsed header says "exited" in words as well, because the shader pass does not reach one and a flattened tab draws nothing else of a pane. Two things about this are load-bearing rather than incidental: - the pty fd is closed the moment EOF is seen. A pane that outlives its program stays in the poll set, and an fd at EOF is readable forever: the session would spin a core behind a pane that looks idle. - the exit status is collected with a bounded wait, never a blocking one. EOF means the program already closed its fds, so it is on its way out and a few hundred microseconds wins that race. A blocking waitpid would hang the whole session on one program that closes its terminal and keeps running; losing the race costs the words "status 0" and nothing else. `keep_dead false` restores the old behaviour for a one-shot layout that wants it. `panes` reports alive/exit_code/exit_signal, so a script can find the pane whose command failed and rerun it.
* sl0ppty: let a child's exit status survive long enough to be readuser2026-08-14
| | | | | | | | | | | | | | | | | | | | The server set `signal(SIGCHLD, SIG_IGN)`, which is not the same as having no handler. SIG_IGN asks the kernel to reap children itself and throw their statuses away, so `waitpid` can never answer: every pane's program exited with "no idea". Nothing read a status yet, so nothing was visibly wrong -- and the headless driver, which is what the whole test suite drives, never set it at all. A suite that is green either way is a suite that would never have caught this. SIG_DFL is what was actually wanted: SIGCHLD's default action is to be discarded, so nothing is interrupted and no handler is needed, but the child waits as a zombie until somebody asks how it went. That makes statuses readable and zombies possible, so pty_close now collects the child it just hung up rather than glancing once with WNOHANG -- bounded to a few hundred microseconds, because a process that outlives a SIGHUP is a larger problem than the process-table entry it leaves behind.
* sl0ppty: contrib themes, a way to look at them, and a handoveruser2026-08-14
| | | | | | | | | | | | | | | | | | | | Six themes, each setting every colour the config knows about. A theme that has fallen behind silently keeps the compiled-in default for whatever it forgot, which looks like a bug in the theme rather than a missing line in it, so a test compares each file against the names config.c actually parses. contrib/theme-tour applies one to a running session, or cycles the lot. It does that by writing over the file the session was started with and letting the config watcher notice, which makes the showcase a demonstration of the watcher as well. HANDOVER.md is for the next context. It deliberately does not restate DESIGN.md or the README; it is the part that was only learnable by getting it wrong — which state is derived and which is intent and what happens when you confuse them, the dwell repaint list, that glyph width and glyph weight are different problems, that settle() in the harness is a sleep in disguise, and that a test pinned to a coordinate it does not care about will eventually look like a regression it is not.
* sl0ppty: find the crossing whichever way the tree is nesteduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | Half the crossings were invisible, and it was the half that depends on how you built the layout rather than on how it looks. Rows of columns gives a full-width row boundary with column boundaries running into it from above and below. Columns of rows gives a full-height column boundary with row boundaries running into it from the sides. On screen these are the same picture; in the tree they are opposite, and the adjacency test only looked for the first — two gaps touching side to side never matched, so a layout built by splitting the other way round had no crossings at all. The test is now that the two touch, in either axis. The crossing itself was never in doubt: it is the column boundary's columns by the row boundary's rows, which is the same cell either way. Corners now hold up to two boundaries of each kind rather than two column ones and a single row one. That asymmetry was an artefact of only ever having seen the first nesting — in the second it is two row boundaries that can line up and need to move together, for exactly the reason two column boundaries did.
* sl0ppty: every place the boundaries meet is a crossinguser2026-08-14
| | | | | | | | | | | | | | | | | Resizing one row of a two-by-two puts its column boundary out of line with the one below, which should leave two crossings on the row boundary, one for each. Only one was found. find_corners took the first column boundary it saw and then accepted others only if they occupied exactly the same columns, which is the aligned case and nothing else: anything out of line was discarded rather than becoming a crossing of its own. It groups by the columns a boundary occupies now, so aligned pairs are one crossing that moves both — still the case worth having, since moving one of a matched pair would put a step in what reads as one line — and everything else is its own crossing moving its own boundary. The test starts aligned, moves one row's boundary, and checks the second crossing appears and that dragging it leaves the other row alone.
* sl0ppty: hold the boundaries, not a seat numberuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Three faults in the corner drag, reported as glitches and picking panes up along the way. The drag held an index into the corner list, and that list is rebuilt from the layout every frame — by a drag whose entire job is changing the layout. Move the crossing and the corners are recomputed: the entry you grabbed can shift position, or stop being a corner, and the index then names somebody else's boundaries. That is exactly "it picks up additional panes and loses them again", and it is the rule this codebase already states about the rect and the collapsed flag, applied to something I stored anyway. The drag now carries the boundaries themselves, so it moves what you grabbed until you let go, whatever the layout does underneath. The test drags a deliberately misaligned grid in circles and checks the boundary that was never part of the crossing is untouched throughout. The redraw was the dwell list again. Anything that arms after the pointer rests has to be named in app_next_deadline_ms or it only appears when something else happens to repaint — so it looked intermittent, which is the worst way for it to be wrong. "corner:" is in the list now, next to the three that were already there for the same reason. And a crossing is two cells wide with nothing about it to suggest it is anything, so it marks itself the moment the pointer arrives, before the dwell that arms both boundaries. Passing over one costs a character; resting on one still explains itself.
* sl0ppty: drag the crossing to resize both waysuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Where a boundary between rows crosses one between columns, that crossing is now a handle: drag it and both move, following the pointer in x and y at once. The two never overlap in the tree, which is the whole difficulty. A column boundary belongs to a split inside one row and stops where the row boundary begins; there is no cell that is part of both. So a corner is found by adjacency instead — the cells of a row gap that have a column gap running into them from above or below — and that has to be computed across splits rather than within one, which is why it is a pass over the tree rather than something draw_node could notice on its way past. It moves every boundary that meets there, not one of them. In a two-by-two the column boundary above the row and the one below are separate splits that happen to line up, and moving only the one you were nearest would leave a step in what reads as a single line. Both get the same delta. The corner's hit is registered after the two gap hits it sits on, so those cells resolve to the corner rather than to whichever gap drew last: it is one target, not an overlap. Resting on it arms *both* boundaries rather than lighting the cell under the pointer, because the hint is supposed to show what is about to move. Finding the corners needed the gap rectangle, which draw_node had been working out inline. It is one function now, shared by the drawing, the hit and the corner search — three things that have to agree about where a gap is down to the cell, and would not have for long.
* sl0ppty: the status line says what the pointer is onuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | The frame's affordances are nearly all one character wide, and the widest one is invisible: a border that splits when you click it looks exactly like a border. Hovering anything now puts a word in the middle of the status line. Read off the hit list rather than tracked. Anything that registers a hit gets a hint by being named in one table, and nothing has to remember to raise or clear one — which also means the hint cannot disagree about what is under the pointer with the thing that would actually be clicked, since both are the same lookup. The status line is drawn after everything else has registered, so the list is complete by the time it asks. It is centred in what is left between the two ends rather than in the row. Centring it in the row would put it under the session name on a narrow screen, and a hint that overwrites the thing it is explaining is worse than no hint; it is simply not drawn when it does not fit, which has a test with a deliberately long pane title. Two of that test's checks were wrong before they were right, in the ordinary way: one searched for a substring that also occurs in the text on the right, and one assumed forty-four columns was too narrow to fit a hint when it was not. Both now assert against measured positions rather than my guess at them.
* sl0ppty: plain ASCII for the frame's buttonsuser2026-08-14
| | | | | | | | | | | | | | _ for minimise, # for zoom, * for the way back, x for close. Box-drawing and block characters were each tried and each looked unevenly spaced, which is the answer: the cells are even every time, and what differs is how much of its own cell a glyph fills. That is a property of the font, not something to be fixed from here, and single-width ASCII of a similar weight is the way to stop asking the question. The measurement stays, so a mark is still yours to change and may be more than one character. The config note now says what the three attempts established rather than what I expected each time.
* sl0ppty: halve the padding between the frame's buttonsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | Reported twice now, with two different marks, so the glyph was never the variable: the gap between two marks is blank cells plus whatever blank each glyph carries inside its own cell, and the second part differs by glyph and by font. An underscore keeps its ink on the baseline and a square fills its box, so two blank cells between them read as more than two between the square and the x. One blank cell instead of two. The difference is still there — it is a property of the font, not of the layout — but at half the size it stops being the thing you notice. Chasing it with per-mark padding would have been compensating for a font I cannot see, and would come apart for anyone who changed a mark. It also gives three columns of every frame back. The three buttons are seven columns now rather than ten, which matters on a frame that only has twenty-four to begin with. The click targets are two cells each rather than three. That is still a reasonable mouse target, but it is a real trade against a row whose rightmost button closes the pane, and worth saying out loud rather than discovering.
* sl0ppty: the squares are back for zoomuser2026-08-14
| | | | | | | | | | | | □ and ▣ read as a pane and a filled pane, which is what zoom does to one; [] and ][ were only ever a hedge against a spacing problem that turned out to be elsewhere. Minimise and close stay as the underscore and the x, so the row is _ □ x. The measured button width stays, which is what makes the choice free: a mark of any width can go in there now, including the two-character ones, so the config note is about how a glyph *looks* beside its neighbours rather than about what will fit.
* sl0ppty: a pane rings on the bar it is put away inuser2026-08-14
| | | | | | | | | | | | | | | | | | | | A minimised pane is not on screen at all, which is precisely the case a bell exists for, and it had nowhere to say so. Its entry on the bar carries the mark now. The mark keeps its own colour there rather than taking the row's, and the same in the tab strip, where it had been drawn in whatever colour the tab was — an indicator in the same dim grey as the label beside it is one you have to already be looking for. It stays in the bell colour whether the entry is hovered or not, because it is not reporting the pointer. Testing it needed the pane to ring itself on a delay: `raw` writes to the focused pane, and focusing a minimised one is what restores it, so there is no way to ring one from outside without also answering it. The test waits for the mark rather than for a duration. Three copies of "count the columns in this short string" became one helper on the way past. The third was about to be a fourth.
* sl0ppty: minimised panes get one row between them, and ASCII marksuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The strip was a row per minimised pane, which let putting things away cost more room than leaving them out. It is one row now, shaped like the tab strip and for the same reason: a list of names you click to go somewhere. Three panes put away leave the survivor exactly as tall as one did, and there is a test that says so, because that is the whole change. The panes on it are no longer laid out at all — the bar draws them, so they have no rect. The row the bar occupies is worked out by the layout and stored, rather than by the drawing working it out again from the same inputs; two copies of that sum is how a click lands somewhere the eye did not. The frame's marks are plain ASCII now. Spacing has been reported twice and the cells were even both times: what is uneven is the glyphs. A light character floating in a mostly empty cell hands that space to the gap beside it, and the fix of picking heavier glyphs only holds for fonts I can see. ASCII has neither that problem nor the double-width one, and "[]" and "][" say maximise and restore about as clearly as anything. That needed the button width measured rather than assumed. It was hardcoded to three cells, which was fine while every mark was one character and silently wrong the moment one was two: the mark would have been drawn over its neighbour, and the neighbour's hit would have covered a cell it did not draw. Also fixes a case that could not happen with a row each: when there is no room for the bar at all, the tab flattens rather than leaving minimised panes with no rect and nobody drawing them.
* sl0ppty: pick a minimise mark that fills its celluser2026-08-14
| | | | | | | | | | | | | | | | | | The buttons looked unevenly spaced. They are not: the cells are mark, space, space, mark, space, space, mark, exactly even, which is worth checking before touching any arithmetic. The gap was in the glyph. An en dash is a short stroke floating in a mostly empty cell, so it donated its own whitespace to the space beside it and sat further from the zoom button than the zoom button sat from close, which fill theirs. A lower one-eighth block has weight comparable to the square and the cross, and is also the conventional minimise icon and a fair description of where the pane actually goes. Noted in the config beside the width warning, since it is the same class of problem: the mark is yours to choose and the two things to know about the choice are that emoji are drawn double-width and that a light glyph will look badly spaced next to heavy ones.
* sl0ppty: minimise a pane into a strip along the bottomuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane can be put away: it leaves the layout, becomes one row at the bottom of the tab, and keeps running. The panes it leaves behind take the space back. Clicking its row brings it back. Minimised is intent, so it sits beside the weight on the node rather than with the rect and the collapsed flag — and layout_node has to be careful not to clear it the way it clears those, or every frame would undo it. That is the only sharp edge in the whole thing and it has a comment. Everything else fell out of rules that already existed. A split divides among the children that still want space, so a split with one live child is that child, exactly as a split with one child already was. A minimised leaf returns from layout_node before its rect is touched, which also skips the floor check — a pane that is one row by request is not a pane that failed to fit. And the row itself is drawn by the same code as a collapsed header, because it is the same thing: a pane you can see the name of and click. Restoring is not a verb. A focused pane is never minimised, checked once a frame, so every route that ends with the pane focused restores it — clicking the row, the control API, and anything added later that moves focus without knowing minimising exists. In a tab that has flattened into a list, minimising has no effect at all, which is what was asked for and also what falls out: every pane down there is already a row, so there is no second thing for it to be. The strip is not reserved when it would leave too little above it to be worth looking at; the tab flattens instead, and arrives at the same place. Minimising the last visible pane is refused with a toast. A tab showing nothing has no way back that can be discovered from the tab, and it is not a state worth being able to reach by accident. The frame's third button costs another three columns, which is why they are drawn right to left and stop when they run out: a narrow pane keeps close and zoom, and loses minimise first.
* sl0ppty: zoom a pane, and give the frame buttons for ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane can now fill its tab and go back again, by C-a z or by a button in the frame's top-right corner. The others keep running; they are just not drawn. Zoom is intent rather than derived state, so it is stored like focus is: on the tab, one per tab, because zooming a pane in one tab has nothing to say about another. Everything else about it falls out of that. It survives the layout being recomputed because it is an input to the layout rather than a product of it. It cannot outlive the pane it names, because the id is checked against the tree every pass and dropped if the pane has gone. And it needed no new arrangement: "show this one and nothing else" is what layout_solo already did for a tab too small to divide, and zoom is the same shape asked for on purpose rather than as a last resort. The frame gets buttons back, having had its split button removed once already on the grounds that one glyph cannot mean four directions. Zoom and close are two verbs a single glyph *can* mean, which is the difference; splitting is still done by clicking an edge, where the edge encodes the direction. They cost six columns of every frame, which is a real tax and why pane_buttons turns them off — the keybindings are untouched by that, because the setting is about the affordance and not the verb. A test in the split-UX suite had been asserting that the frame spends nothing on buttons. It now asserts the weaker thing with them on and the original, stronger thing with them off, which says what actually changed rather than lowering a number until it passed. One test of mine was wrong in an instructive way: it counted visible panes across every tab, and panes in a tab that is not on screen were never laid out, so they carry whatever rect and hidden flag they had last time. The comment on those fields says exactly that. It counts the current tab now.
* sl0ppty: one save, one reloaduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | A double "config reloaded" was reported, and the immediate cause was me saving the file twice in a row — but looking properly turned up a real one underneath. One save is not always one inotify event. Watching a directory for CLOSE_WRITE, MOVED_TO and CREATE, an editor that *creates* the file rather than rewriting it produces CREATE and then CLOSE_WRITE, measured, and each one was reloading the config and announcing it. Rewriting in place gives one event, and so does the rename-over that sed and vim do, which is why it took a specific kind of editor to see it. Events now arm a deadline rather than reloading, and the reload happens once the file has been quiet for eighty milliseconds. That collapses the double, and also means an editor that writes in chunks is read after it has finished rather than halfway through — which should make the mid-save parse error, the thing the fail-open behaviour exists for, rarer than it was. The test counts the toasts, because toasts stack: a config that reloaded twice says so twice, which is exactly what was seen on screen. I checked it against the un-debounced build first and it reported two, so it is testing the thing it claims to.
* sl0ppty: saving the config reloads ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The reload existed but you had to ask for it, which is the wrong shape for the thing it is mostly used for: changing a colour and looking at the result. The server now watches the config and re-reads it when it changes. It watches the config's *directory* and filters by name, rather than watching the file. Editors overwhelmingly save by writing a temporary file and renaming it over the target, which swaps the inode out from under a watch on the file itself — it fires once and then never again, which is worse than not watching at all because it looks like it works. The test writes in place *and* renames over, because only the second one would have caught that. Reloading goes through the same call the command does, so it keeps the fail-open behaviour: a file that does not parse leaves the running config alone and says so in a toast. That matters more here than it did for the command, because a file caught halfway through a save is a normal thing for a watcher to see rather than an operator mistake — and there is a test for it, and for the recovery once the file is valid again. Turning it off is a command-line flag rather than a setting, because a setting would be circular: you would have to reload the config to stop it reloading the config. --no-reload only stops the *watching*; asking explicitly still works, which is the distinction the flag name is trying to make. The test is a live one because inotify lives in the server's poll loop and the headless driver has no server. It polls for the colour it expects rather than sleeping a guess, so the passing cases cost milliseconds; the two cases that assert nothing happened are the only ones that wait, because proving a negative has to.