aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.c (follow)
Commit message (Collapse)AuthorAge
...
* sl0ppty: make the finder a modal, and a picker worth usinguser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | It was a bare coloured rectangle painted over whatever pane it landed on: no border, no title, no scrim, no way out you could see. It read as output from the program underneath rather than as a window in front of it, which is the one thing an overlay has to get right. It is now a modal like the cheatsheet -- same frame, same scrim, same close button in the place a pane keeps one -- differing in the way that matters: the cheatsheet is read, so any key dismisses it, while this one is used, so it keeps the keyboard until it is answered. The list was the other half of the problem. It showed `1:` for a tab with no name, which looks like something failed to load; a row for a pane with no purpose was three columns of nothing; there was no mark for the pane you are already in, no count, and nothing to say a list had more below the tenth row -- so a list that scrolled looked like a list that ended. An empty result was an empty box, which reads as a broken finder rather than a wrong query. Now: a query field that looks like one (so it shows where the typing goes before you type), the current pane marked, tab/title/purpose told apart by weight, the row under the pointer lit, "N of M" in the footer, arrows for what is off each end, and "no pane matches that" when there is nothing. Keys, because a picker in this shape is reached for by hands that already know one: C-n/C-p, tab/shift-tab, home/end, page up/down, C-u to clear the query, and wrapping at both ends -- one press of Up for the last entry is worth more in a short list than overshoot protection. Backspace deleted a *byte*, so one press through a multi-byte character left the rest of it in the query; it deletes a character now, the way the rename editor already did. The finder owns the pointer as well as the keyboard: a press that is not on one of its rows dismisses it and stops there, rather than also landing on the layout behind and focusing the pane you were clicking away from. Found on the way: CSI Z (shift+tab) was not parsed at all, so it reached neither a binding nor the program in the pane -- every terminal sends back-tab that way, and it is the one chord whose shift lives in the final byte rather than in a modifier param.
* 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.
* 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: 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.
* 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.
* 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: 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: 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: 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: 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: 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: 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: a pane that rings says souser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | libghostty-vt already had a BEL effect; nothing was listening. A pane now marks its titlebar when one arrives, and stays marked until the pane is looked at. It marks the *tab* as well, which is the half that makes it useful. A bell exists for the pane you are not watching, and a mark that only appeared on screen would be missing precisely when the pane is in a background tab — which is where an agent finishing a job, or a build wanting input, actually happens. Looking at a pane is the acknowledgement, and it is done once per frame before anything is drawn rather than while the pane itself is painted. The tab strip is drawn first, so clearing during the pane's own paint left the strip reporting a bell that had already been answered for one frame — visible as a stale dot that went away on the next unrelated repaint. Doing it up front also means every route to "this pane is focused" clears it, not only the routes that thought to. The mark is a configurable string rather than a fixed glyph. The obvious choice is an emoji bell, emoji are drawn two columns wide by many terminals, and screen_text books every chrome cell as one — so the default is a narrow bullet and anyone who knows their own terminal can say otherwise. config.kdl has no \\u escape, so it holds the character itself; that cost a round trip to notice. bell_indicator false makes a bell silent and invisible, which is a real choice and not an oversight, so it is spelled out in the config.
* sl0ppty: one theme name per decision, and a tab you can seeuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The count, since it was asked: no hardcoded colours at all in the drawing code. Every colour already came from the config. The problem was the other way round — six names were serving about thirty decisions, so the names were the thing that was hardcoded. frame_focus was the focused frame *and* the split guide *and* the resize handle *and* the tab hover *and* a collapsed row under the pointer. Recolouring one recoloured five. There are thirty-five entries now, one per surface, each documented in config.kdl by being its own default. Several still share a value: that is a statement that they look right together, not that they are the same thing, and the difference only matters the first time someone wants to change one of them. The tab you are in is filled rather than merely bold, which was the ask. Bold alone is easy to miss on a strip of short labels, and it was the only thing distinguishing the tab you were in from the four you were not. That immediately raised a question the old scheme could not ask: what does hovering the active tab look like, when it already has the strongest treatment on the strip? Keeping its fill and brightening its text, because it is still the tab you are in and clicking it does nothing. My first version gave it no hover at all, which reads as the hover being broken rather than as the click being pointless. Three surfaces were still borrowing a name that did not describe them: the in-place rename editor was wearing the button colours, a pane's OSC status text and a suspended pane's hint were wearing the frame's. They have their own now.
* sl0ppty: give the scroll arrow room to breatheuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | "▲12" ran the arrow into the first digit. U+25B2 is drawn wide enough in plenty of fonts to touch whatever follows it, and the pair then reads as one smudged token instead of an arrow and a number. Both places that draw it — the pane frame and the status line — now put a space between. Both were also measuring the indicator in bytes. The arrow is three bytes and one column, so strlen reserved two columns that were never drawn, holding the whole thing further from the edge than it needed to be. The frame now counts cells arithmetically and hit-tests the width screen_text actually drew; the status line counts codepoints, which is exact for a string whose only non-ASCII is that arrow. Fixing the measurement immediately made the bottom line worse, which is a pleasing kind of bug: the two-column overcount had been supplying the gap between "scrolled" and "pane 1/1" by accident, and correcting it left them adjacent and reading as one phrase. The separation between right-hand clusters is now deliberate and two columns wide, rather than however much the arithmetic happened to be wrong by.
* sl0ppty: a pane has to clear the floor in both directionsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two panes stacked on top of each other could be squeezed to four columns wide and nothing collapsed, at any width. The fit check lived only on split nodes, and a split node only ever asks about the dimension it divides: a row split asks about height and never about width. A leaf was asked nothing at all. So for a stacked layout there was no node in the tree responsible for noticing that its panes had become unusably narrow — the check existed, it was just looking the other way. A leaf is the only node that can see both of its own sides, so it now checks both. The floor is per pane and the same in every arrangement, which is what made this a hole rather than a missing feature. That immediately made resizing dangerous, which is the more interesting half. Dragging a divider one cell too far could push a pane under the floor and collapse the entire tab into a list — a drastic answer to a nudge, and it looked like the session falling over rather than a limit being reached. So the transfer stops at the floor instead, the way every other resizable thing stops. The clamp went into transfer_weight rather than into the caller, because the mouse reaches it through drag_edge and the keyboard through resize_focus, and my first attempt clamped only the mouse. A limit that one of two paths respects is worse than none: it looks fixed until you use the other hand. It also has to round the weight *up*. The weight that buys exactly `floor_` cells is a fraction, and rounding it down buys one cell fewer — which is precisely the cell that puts the pane under the floor. The first version rounded down and collapsed anyway, one drag later than before. Two tests changed with real content rather than arithmetic: one used a seventy-six by eight screen whose panes were four rows tall, which is under the floor and should always have collapsed, and one asserted a squeezed pane stayed "at least 3 columns" when it now stops at 24 exactly.
* sl0ppty: the tab strip answers the pointeruser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | Tabs and +tab were clickable and looked identical whether the mouse was on them or on the other side of the screen. They light up now, in the same language the collapsed stack already uses: foreground only, immediately, no dwell — this is feedback about where the pointer is, not an action being armed. Colour and weight are kept independent. Weight says which tab you are in, colour says where the pointer is, so hovering the tab you are already in does not make it look like you left it. That is the case worth having a test for and it has one. Each label is drawn, its width read back, and redrawn in the hover colour only if that width turns out to be under the pointer. Two paints of a few cells buys the guarantee that the lit cells are exactly the registered ones — the alternative was measuring the label separately from drawing it, which is the same "two implementations of one rect" that D-one exists to prevent. The pointer test is now shared with the stack header rather than written twice, since that is three places asking the same question of a rect they are about to register.
* sl0ppty: the status line says which pane, not just how manyuser2026-08-14
| | | | | | | | | | | | | | | | | | | A bare count answered "how big is this tab" and left "where am I in it" to be worked out by counting frames — which is exactly the question you have once a tab has collapsed into a list and only one pane is open, and exactly when counting frames stops working. It reads `pane 2/5` now. It also stops looking like the session count in the opposite corner. Two numbers in two corners, both rendered "N panes" and disagreeing, was a puzzle the reader had to solve once and then remember. Both bars also take a `status_pad` for how far they are held off the left and right edges. They were using `gap * gap_aspect`, which is the space around the *panes*: the two match at the defaults and there was never a reason they had to be the same number. The count is kept as a fallback for when nothing is focused, which should not happen but says something true if it does.
* sl0ppty: the status line counts this tab's panesuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | The strip along the top counts every pane in the session, which is the right number for a strip that lists every tab. It is the wrong number for "how big is the thing I am working in", and nothing answered that. So the line along the bottom carries its own count, of this tab only. The two disagree as soon as there is more than one tab, which is the point of having both — and the test says so directly rather than checking each in isolation. It sits hard against the right edge with the pane's state inboard of it, which is the order the strip above already uses for its own pair: the number is always there, the mode only sometimes, and a thing that comes and goes should not shove a thing that does not. Both counts now say "1 pane" rather than "1 panes". The top has been ungrammatical since it was written and a test had faithfully pinned it that way; adding a second place to say it made that worth fixing rather than copying. One of my own tests had to go: it asserted the bottom line contained no pane count at all, on the reasoning that the top already had one. That was right when it was written and is exactly what this change reverses, so it now checks the thing that is still true — the bottom line is not a second copy of the tab strip.
* sl0ppty: a status line along the bottomuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The strip along the top answers "which tab" and "how many panes" — questions about what you could switch to. Nothing answered "what am I looking at", and three of the states a pane can be in were discoverable only by being surprised by them. So the bottom line is about the focused pane and deliberately repeats nothing from the top: the session it lives in, the tab, what the pane calls itself (its purpose if it declared one, otherwise its title), and on the right, the state worth knowing — scrolled back and how far, on an alternate screen, not started yet, or exited. Right side budgeted first, the same rule the tab strip and the pane frame already use, so a long name cannot push the state off the end. The session name had to be plumbed in: the server knows it because it opened the socket under that name, and the app had no reason to until now. Reserving a row is not free and three tests noticed, which is the correct number to notice. Two were arithmetic — a chrome budget written as rows-5 and a six-row session whose pane no longer had room for a frame at all. The third was more useful: test_tiny_panes_have_no_targets asserted "no border hit that is one row tall and under four wide", which is a description of the shape a collapsed header used to have rather than of the thing being tested, and it started catching the perfectly legitimate side border of a pane that was merely short. It now says what it means — no split targets on a *header* row — and is both stricter and immune to the layout getting a row shorter again.
* sl0ppty: a collapsed pane is drawn as a pane, closeduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | Three things about the stack, and the third is the one that matters. The hover feedback loses its filled bar and highlights the foreground instead: the rule takes the focus colour, the title goes white and bold. A solid block was louder than the thing it was telling you, and these rows have to sit in a list of their own kind without shouting. Titles get air. A header read `charlie─────` with the rule welded to the name, which parses as one longer word; it is now `╭── charlie ───────╮`, with a space guaranteed on each side and a configurable `title_inset` between the corner and the title. The inset applies to the open pane's frame too, for whichever edge its title is anchored to, so both agree about what a title looks like. And the corners. A collapsed row is not a label that happens to sit above a pane — it *is* a pane, closed — so it now draws the top edge of one, corners included, in the same style the open pane below it wears. A stack of them reads as a stack of panes rather than as a table of contents, which is what the thing actually is. It costs two characters and a comment. Headers stay left-anchored at the inset while the open pane keeps title_align (centred by default). That looks inconsistent written down and right on screen: a list is scanned down its left edge, and a frame is looked at whole.
* sl0ppty: a row in the stack lights up under the pointeruser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | Once a tab is a list, its rows are what you are picking from, and until now they gave no sign of being pickable at all — a flat rule that looked the same whether the mouse was on it or on the other side of the screen. Lit immediately rather than on dwell. The dwell that keeps a split guide from flashing as the pointer crosses a border is exactly wrong here: this is feedback about where the pointer *is*, not an action being armed, and a list you are scanning has to track the mouse continuously or it feels broken. The finder's language for "this is the row", a solid bar, rather than a brighter rule. FRAME_FOCUS would have read as "this pane is focused", which is a different claim and one a collapsed row cannot make. Hovering deliberately does not open the row. focus: already ignored everything but a press, so this needed no guard — but it is the property worth keeping and now has a test: a list that opened panes as the mouse crossed it would shuffle under you while you read it. Clicking is still what opens one. The lit row is decided from the same rect that is registered as the row's hit, in the same function, so what lights up and what would be clicked cannot drift apart.
* sl0ppty: a tab is laid out or it is a list, never half of eachuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Collapsing was a local decision: the subtree that could not fit flattened and everything else carried on. In practice that produces a screen with a full-height pane on the left and a stack of headers on the right, which explains neither what just happened nor what to do about it. Seeing it is enough to know it is wrong. The question is now asked of the whole tab and answered once. If any node cannot give its children their floor, every pane in the tab becomes a row and the focused one opens below them — including the panes that would have fitted perfectly well on their own. That costs something real and it is worth being honest about it: a wide screen with one cramped column now flattens entirely, where before it would have kept the rest of the layout. The trade is that there is one collapsed state instead of a spectrum of partial ones, and a rule you can hold in your head is worth more here than squeezing the last working layout out of a bad size. Mechanically this made layout simpler rather than more complex. layout_node no longer decides anything about presentation; a node that cannot fit sets a flag and returns. Laying out happens twice — once to ask, once to do — and the probe is the real layout with resizing switched off rather than a second copy of the fitting arithmetic, because two copies of "does this fit" is exactly how the split guide and the layout would begin to disagree. The probe not resizing is the load-bearing part: a pane it lays out normally may be a header by the time the real pass runs, and a pane about to be collapsed must not be told a size it will never show.
* sl0ppty: collapsing flattens the tree instead of nesting headersuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Collapse was a local decision that stayed local all the way down, so a tab whose root did not fit could show one pane as a header and then, inside the child that did fit, three more panes side by side. That is a degraded mode still spending rows on an arrangement it has no room to express, next to a pane it has reduced to a single line. Now a subtree that does not fit flattens: every pane under it becomes one row, in one list, with the focused one opened below them. Nesting is precisely what there is no space for, and a reader in that state is asking which pane, not which arrangement. The better reason is that the old shape made panes unreachable. mark_collapsed gave every leaf under a collapsed subtree the *same* rect, and draw_collapsed registered one hit for first_leaf_of(). Three stacked panes therefore became one header whose only action was focus on the first of them: the other two could not be clicked at all, and directional focus could not separate them either, because their rects were identical. One row per pane fixes that by construction rather than by adding a case. Headers sit together with the body below rather than the open pane keeping its position in the list. Switching then moves only the content while the list you are picking from holds still, and the tree order that an accordion would have preserved is the thing being flattened away. The stack's guard counts panes rather than children now, since that is what it spends a row on each of, and layout_solo flattens the same way so there is one mental model rather than two. subtree_has() went with them. One regression, caught by the test that exists for it: collapse_leaf set `content` to the header rect, and a hidden pane's content is the size the program inside still believes it has. It is not resized on the way in and must not appear to have been. Nothing draws a collapsed pane's content, so writing a header rect there would only have made the lie more convincing.
* sl0ppty: the resize hint names the verbuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | The dotted line said "this is a handle" and left what pulling it would do to be worked out from the fact that it sits between two panes. An arrow in the middle of the line says it outright. It points along the axis the boundary *moves*, not the one it is drawn on: a divider between two columns is a vertical line that travels sideways, and the travelling is the half worth showing. So a column boundary gets a sideways arrow and a row boundary an up-down one, which is the opposite of the line each is drawn with and the right way round for the gesture. Always bold, in both states, because it is the one cell of the hint that is meant to be read; the line around it carries whether you are hovering or holding. Double arrows rather than U+2194 and U+2195. Those carry emoji presentation and are widely rendered double-width, and screen_text books every chrome glyph as exactly one cell, so one that drew as two would shift the rest of the row — the same trap the split-guide arrows had to avoid. U+21D4 and U+21D5 have no emoji form, and happen to rhyme with the doubled line the active state already uses.
* sl0ppty: the resize gap says it is a handleuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | The gap between two panes has always been draggable and has always looked exactly like nothing. Resting on one now marks it dotted, and the mark doubles and goes bold once you actually have hold of it — because "you could move this" and "you are moving this" are different claims, and the second should not have to be inferred from the panes changing size underneath you. Dotted for a possibility, weight for a commitment: the same grammar the split guide already uses, so there is one visual language to learn rather than two. Vertical boundaries get vertical marks and horizontal ones horizontal, along the axis the boundary actually runs. It arms on dwell like the split guide, because crossing a gap on the way to the pane on the other side is the most ordinary mouse movement there is, and a pointer already carrying a pane is not shopping for a boundary — a title drag passing over a gap arms nothing. The hint is tested against the very rect registered as that gap's hit, in the same loop iteration that registers it, so it cannot appear anywhere the click would not land without having to trust a lookup to agree. One thing that would have made this silently useless: app_next_timeout only scheduled a repaint for hovers over `border:` and `title:`, so a hint that arms on dwell over `edge:` would only have appeared when some unrelated event happened to repaint the frame — visible when the session was busy and not when it was idle, which is the worst way for it to be wrong. Everything that arms on dwell has to be listed there, and now is.
* sl0ppty: a floor under splitting, and an arrow saying which wayuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two changes to the same affordance. A border stops being a button below `min_split cols=32 rows=8`. This is a different question from min_pane, which is where the layout gives up and collapses a pane: two twenty-five column panes technically fit and are not worth having, so the number that decides whether to *offer* a split is larger than the one that decides whether a pane can survive. It is clamped to never go below min_pane, because refusing to offer what the layout would collapse anyway is the floor under the floor. The test is the layout's own arithmetic rather than an approximation of it: the same floor, the same gap, and the same child count the split would create — which is one more sibling when the parent already splits that way and two when it does not. The guide's standing claim is that drawing and the layout cannot disagree, and a guide promising a split that immediately collapsed would be exactly that. The keyboard obeys the same floor, with a toast. A rule only the mouse followed would mean the same session splits or refuses depending on how you asked, and whether there is room cannot depend on that. The control API and layout files are *not* bound by it: a script asking for a pane is declaring what it wants rather than being offered something, and D6's responsive collapse is what catches it there. That distinction was not free — app_split_pane went through the same helper as the keybinding, so the API was silently bound too until a test noticed, and the interactive path is now its own function. The guide also gained an arrow on the dashed line, pointing at the half the new pane will take. The line already said where the boundary lands and left which side to be inferred from which border you happened to be touching. Pointers for left and right rather than the matching triangles: U+25C0 and U+25B6 carry emoji presentation and are widely rendered double-width, while screen_text books every chrome glyph as exactly one cell — so one that drew as two would shift the rest of the row. U+25B2 is already the scroll indicator, so the vertical pair is known good here. Raising a floor invalidates every test that quietly relied on the old one, and nine of them did: sessions sized for a world where a sixty-column terminal could be split into columns. They are sized for the rule now, and the two that were really about *layout* collapse rather than the affordance now split over the API, which is the path that still means "do it anyway".
* sl0ppty: pane states are a table, not three hardcoded knobsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | dim_unfocused, drag_grayscale and drag_dim were three knobs describing two situations, and there was no way to say anything about the third, fourth or fifth. They are replaced by a table: a state names a chain, and the chain is exactly as expressive as the global `shaders` block because it is the same parser. states { dragging { } drop_target { grayscale amount=200; dim amount=140 } suspended { grayscale amount=180; dim amount=90 } scrolled { tint amount=30 color="#ffcc88" } unfocused { dim amount=90 } } A pane is usually in several states at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. Fixed rather than read from the block, because it is a ranking by urgency rather than a preference — a pane you are holding should not be recoloured by anything, and a mode the whole screen is in outranks an ambient hint about one pane. They do not stack, which is the same call the drag policy already had to make by hand: two reasons to be grey compound into a muddy grey that reads as neither. Defaults are the old drag behaviour and nothing else, so out of the box this changes nothing. drop_hover is now separable from drop_target. The pane under the pointer was already distinguished by its border; it can now differ in colour too, and defaults to the same chain so it does not have to. Two states asked for did not survive contact with the code, and it is worth writing down why: `dead` cannot exist. app_reap() runs every iteration of the server loop, before the paint, and closing the last pane closes the tab — so a pane whose program exits is gone before a frame containing it is ever composed. A `dead` shader would name something that could never draw. Making it real means keeping dead panes until they are dismissed, which is a feature about lifetimes and not about colour. `suspended` could not be seen either, for a much smaller reason: draw_cb drew its "press a key to run" label and returned before reaching the shader pass. It falls through now. A pane that has not started is a state worth colouring, and its label is the only contents it has.
* sl0ppty: positional shaders, and a config block to ask for themuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Six passes that are pure functions of where a cell is: vignette, gradient, zebra, ruler, margin, spotlight. The position was already in the context and nothing had used it; these are what it was for. The one worth having on its own merits is `ruler`. A column guide that costs no columns, needs no support from the program in the pane, and works in anything — an editor that has never heard of one, a pager, a shell. It marks the background only: a guide that recoloured the text would be making the code it marks harder to read, which is the opposite of the job. `margin` is the same idea from the other side, everything past a column receding so an over-long line visibly runs out of the pane you meant to use. `spotlight` is the first thing to need something new in the context. The cursor belongs to whichever pane is focused, so it is handed only to that pane — otherwise every other pane's light would chase a cursor somewhere else. It leaves everything inside its radius completely alone, because dimming the text you are actually working on to make it easier to find would be silly, and ramps rather than stepping at the edge. `vignette` and `spotlight` both measure distance with a row counting double, because a cell is about twice as tall as it is wide and an effect that claims to be round has to say so in cells. The layout's own centre-distance already does this. The test for it is that on a 41x21 pane — square on screen, not in cells — the top edge and the left edge come out equally dark. Scanlines are not a seventh shader. A CRT scanline is `zebra` with a band of one row, and shipping the same arithmetic under two names would be pretending they are different things. These are not policy, so they needed a way to be asked for, and a KDL block whose children are the passes gets it without inventing a syntax: order in the block is order in the chain, because a chain is a sequence, and listing a shader twice is how you get two rulers. Unknown names are refused with a warning rather than guessed at. Empty by default — these are yours to want, not opinions we hold.
* sl0ppty: dashed borders and a deeper push-back while dragginguser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two changes to the same moment, on opposite sides of the chrome boundary. Every pane that is not being dragged now draws a dashed border, because every one of them is somewhere the pane could be dropped and a dashed frame says so without needing a legend. The pane in your hand keeps a solid one, so the two states are distinguishable by shape and not only by colour — which matters on a terminal whose palette makes the greying subtle, and to anyone who would not see the greying at all. The pane actually under the pointer still takes the existing drop-target highlight on top of the dashes: these are all targets, that one is the one you are on. Borders are chrome, so this is not a shader and could not have been. It lives in draw_frame, keyed off the same drag.moved that gates the greying, so a press that turns out to be a click neither greys nor dashes anything. The greying itself gets a second pass: desaturated *and* darkened, rather than only desaturated. Grey alone still competes for attention at the same brightness, which is what made the dragged pane fail to pop. Two shaders instead of one that does both, because "colourless" and "recessed" are different things to want and want separate knobs — drag_grayscale 200 and drag_dim 140. This is the first thing to actually use the chain, and it is what the chain was for. The knob that used to mean "off" no longer does, and the test that asserted it caught that immediately: drag_grayscale 0 now leaves drag_dim still dimming. Rather than couple them, the test became three — both off, each alone — which is better coverage of a two-knob effect than the single case was of a one-knob one.
* sl0ppty: drag greys the other panes, and dim_unfocuseduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first two consumers of the shader pass, and between them they settle how a pane gets one. Dragging a pane by its title now greys every *other* pane, so the one that is moving lifts off the page — which is the thing that made a colour pass worth building. It engages only once the pointer has actually moved, because a press on a title that turns out to be a click would otherwise flash the whole session grey on its way to doing nothing. dim_unfocused fades everything but the focused pane and is off by default: an always-on effect is the kind you stop seeing and start fighting, so it should be asked for. Both are derived every frame rather than attached and cleared. Focus and drags move through far too many paths — hover, click, the finder, a close, a layout apply, any keystroke ending a drag — to keep an attachment in sync with, and a pane left grey by the one path that forgot is a bug that only turns up in front of someone. The rect and the collapsed flag are recomputed every pass for exactly this reason; this is the same rule applied to colour. It also makes the awkward cases free: dropping into the margin, a keystroke ending the drag, or the dragged pane being closed under it all un-grey without anywhere having to say so, and there are tests for each because "it turns off again" is the whole risk. The two policies replace rather than stack. Dragging an unfocused pane while dim_unfocused is on would otherwise give it two reasons to be grey, and they compound into one muddy grey that reads as neither; the drag wins, so the dragged pane is at full colour whatever the focus says. shade_apply no longer caps at SHADE_MAX. That cap was right when the only shaders were the ones a pane had attached, and wrong the moment the session derives one on top of them: a full stack would have silently dropped it. The count is the caller's to bound. The chrome check that mattered was already there: test_drop_target_is_visible samples a frame corner mid-drag, and it still passes, because the pass runs between the contents and the chrome drawn over them.