aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.c (follow)
Commit message (Collapse)AuthorAge
* slosh: greet an attach with the logo, wearing a random shaderuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | logo.txt is the source of truth; the Makefile bakes it into build/logo.h the way version.h is baked. When a client attaches (MSG_HELLO turning a connection into the display), the logo appears centered over everything for splash_ms (900, 0 for never), on a cleared backdrop, in the accent -- and one of six effects runs over its rect through the ordinary shader pass: a diagonal sheen, plasma, rings, sparkle, a left-to-right reveal, a breathe. Which one is picked by the splash's own timestamp, so every attach looks a little different without anybody keeping a counter. Every effect reads the clock, so the frame clock runs only while the splash is up (note_animation, same as any animated chain), and `since` in a splash context is milliseconds since it appeared, so one-shot effects sweep exactly once. Any key or click ends it and still lands where it was aimed: a greeting is not a modal. A screen the box does not fit on gets no greeting rather than a cropped one. Headless and scripted sessions never attach, so the whole fast suite never sees it; {"cmd":"splash"} triggers it on demand, which is what makes it testable from a scripted session -- and lets you replay the greeting whenever you like.
* slosh: defaults most people can live in, said by shadersuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
* slosh: rename the project from sl0pptyuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | sl0p + pty was error-prone to type and the zero made it worse; slosh (slop + sh) is one syllable, impossible to typo, and slosh.foo is ours. Mechanical rename everywhere it appears: binary, env vars (SLOSH, SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-<uid>, $XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards, src/sl0ppty.h -> src/slosh.h, contrib/sl0ppty-dev -> contrib/slosh-dev, the driving-slosh skill, docs and tests. The sl0ppi fork references and the sl0p theme stay: one is history, the other is the brand. The rename surfaced a latent bug in test_osc5577: the hello-reply answer always appeared on screen twice (ECHOCTL echo plus cat -v), but a seven-character implementation name happened to wrap the second copy across the 60-column boundary, so counting substrings in wrapped text found one and passed for the wrong reason. A five-character name moved the wrap and exposed it. The loop guard itself is correct -- with echo off before the request there is exactly one reply -- so the test now counts on de-wrapped text and tolerates the pane's own echo.
* sl0ppty: clang-format the treeuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The bumpy one. `make fmt` over every `.c` and `.h` outside `vendor/`: 32 files, 1,843 lines rewritten, no behaviour changed. Nothing here is worth reading -- the next commit adds it to `.git-blame-ignore-revs` so `git blame` walks past it. Two thirds of it is alignment the formatter now owns: trailing comments in a struct, continuation lines under a signature. The rest is the hand-packed tables opening up to one entry per line -- `ACTIONS`, `NAMED_KEYS`, `ACTION_HELP`. That is a real loss of density and a real gain in diffs: adding an action was a reflow of the block and is now one line. Anything worth keeping packed can be fenced with `// clang-format off`; nothing was, on the grounds that a table is the thing a formatter is for. **One change is not formatting.** `src/config.h` had three trailing comments glued onto one field: uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ -- `toast_ms`'s and `scroll_lines`' comments, landed on their neighbour by an earlier edit, documenting the wrong things ever since. It is in this commit because it is what caught it: clang-format could not settle on that line, wrapping it one way and then re-indenting the wrap on a second pass, so `make fmt` was not idempotent. The comments are back where they belong and it is. Verified after the reformat, not before: `make clean && make all` is warning-free, `make retest` is 1801 checks green, and `make fmt` twice in a row changes nothing the second time. A formatter that alters behaviour would be worse than none, so the gate was the whole suite rather than a reading of the diff.
* sl0ppty: double-click a word to select ituser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pane_select_word()` was written and never wired to anything: no declaration, no caller, no way to reach it. This is the rest of it -- the gesture, the setting, and the tests. **The word is defined by what it stops at, not by what it is made of.** Anything that is not blank and not a listed separator belongs to a word, so CJK, accented letters and emoji need no table and come out whole; whitespace always separates, listed or not, because a double-click that selected the line would be a different gesture arrived at by surprise. Wide glyphs continue the run they are in rather than each being their own word -- otherwise every CJK character is a word. `word_separators` defaults to `"'`()[]{}<>;,|`, and what it leaves *out* is the decision: `/ . - _ : = @ # % ? & + ~ $` stay inside words, so a path, a dotted name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are each one thing you double-clicked to copy whole. `:` is the difference between copying an error location and copying half of one, which is why kitty keeps it too. The gesture reuses what selecting already has: the press starts a selection at the cell -- which anchors a drag out of the double-click -- then widens it to the word, and the release path that already copies a drag copies this. So the word *is* the copy, in the one place that rule lives, and the highlight is the copied span rather than an approximation of it. Tracked per cell as well as per pane, so clicking two different words quickly selects the second rather than everything between them. A program that asked for the mouse still keeps its clicks, with shift as the escape hatch, exactly as the drag has it. **One thing this needed on the way: quoted values were dumped unescaped.** The default separator set opens with a quote, and `cb_add(&b, "key \"%s\"\n", ...)` wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking the property the dump exists for. The escaping loop already existed inside `cb_chord`, for the same reason and with the reasoning written down; it is now `cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`, `editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those, load-bearing for this one. **And `--dump-config` does not do what four comments and the docs said it did.** It calls `config_dump_defaults()`: every setting with its *default*, never the value your config gives it. The function name was the honest one. Corrected in `docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's own docstring, rather than changing what the flag does -- starting a config from it is the documented use, and `--check` is what reads yours. There is no way to ask for the effective config; that is a gap, not a lie, and it can be a flag when somebody wants it. Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight being exactly as wide as the copy; a separator, a blank cell and a single click each leaving the clipboard alone; two clicks on different cells not being a double; two slow clicks not being one; the separator set honoured from a config, including a quote and a backslash carried through KDL escaping; a word in a scrolled pane coming from the row on screen; and a mouse-tracking program keeping its double-clicks until shift takes them back. Three of those failed first because the test asked for the answer twice -- a second `worded()` call is a second pair of clicks -- and one because it mapped string indices to columns without counting wide glyphs as two.
* sl0ppty: workspaces, and layouts that write themselvesuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **Identity is the tab's `purpose`** -- `project:<name>.<hash8>` of the resolved path, the shape D3 already published -- so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `label` only ever knew what a layout said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell -- which made arranging a project and writing it down two jobs instead of one. The kernel knows what the label does not: a pty has a foreground process group, and that group is by definition the job that owns the terminal, so the answer is read the way `live_cwd` already reads `/proc/PID/cwd`. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal), and an ephemeral pane. A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" was true of the machinery and quietly false of the size: handed no limit, lib-vt picks its own -- 10,000 *bytes*, which measured at 622 lines of an 80-column pane, less than one `make` run. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
* sl0ppty: `C-a Enter` splits the longer wayuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One key for "I want a pane and I have no opinion about where". It cuts across the pane's longer side, so the two halves come out as square as the pane allows, and pressing it four times on a 100x30 terminal tiles the tab the way you would have done it by hand: a column, then rows inside the new column, then rows again, then a word saying there is no room left. "Longer" is a question about what you can *see*. A cell is about twice as tall as it is wide, so 80x24 is a wide pane (80 against 48) while 40x24 is not (40 against 48). `gap_aspect` is already this program's answer to how many columns a row is worth, so that is the number used rather than a second constant meaning the same thing -- and a config that says cells are square gets a square answer. There is a test at 74x44, the size where the two readings disagree: square cells split it into columns, the default splits it into rows. The fallback is the other half of "sensible". A pane that is wide but ten rows tall wants cutting across; if `split_fits` says that will not fit, the other axis is tried before giving up. And it says which way it went -- `split right` or `split down`, the same two words the border click already uses -- because one key that can do two things has to tell you which it did. **`split_fits` had a hole this action made obvious.** It only ever asked about the dimension being divided, so a pane four rows tall on a short terminal "had room" for two columns: the width was fine, and the two panes it produced were immediately collapsed into a list by the layout. The border was offering that, and both split keys were doing it. It now also requires the axis it does *not* divide to clear the floor, because splitting cannot improve that one -- the same hole `layout_node` closed at the leaf, one level up. On a 100x8 terminal all three now say "no room to split" instead of undoing the arrangement. Verified: nine checks in `tests/test_split_ux.py` -- the axis chosen for wide and for tall panes, the aspect deciding at 74x44, both fallback directions, the refusal and that the explicit keys refuse the same way, and that it appears on the cheatsheet under `enter` and in the palette by name. The existing 65 checks about what the border offers still pass, which is what I was most worried about.
* sl0ppty: layouts are `*.layout.kdl`, and each loader says which it gotuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
* sl0ppty: `C-a >` sends this pane to the next tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C-a > push it to the tab after C-a b into a tab of its own C-a < push it to the tab before Three actions rather than three special cases in the key handler, which is what puts them in the cheatsheet and the palette without another line of code -- and the palette is where you find a thing you did not know to look for. You do not follow the pane, and a toast says where it went: "moved to tab 2 (api)". A key you might press twice needs to say what it did, and following would take you away from what you were doing. The tab's *name* is read before the move and its *number* after: emptying this tab removes it and shifts everything along, so the number read first is not the number anybody will see. When the tab you were in is the one that goes, you arrive in the survivor because there is nowhere to stay. `>` and `<` are the shifted period and comma, and the defaults bind both halves -- a terminal does not report shift for punctuation, so `>` arrives as a bare period the way `?` arrives as a bare slash. The config loader already did this for a chord you write by hand; the defaults had to do it themselves. The cost is that `.` and `,` are spoken for, which is the cost `/` already pays for `?`. `b` is for break out, the word tmux taught everybody for this. **Two things the tests caught, both mine.** `panes` reported a pane's tab as an index while every command that takes a tab wants an id -- and my own test conflated them, passing only because the two agree until a tab is removed, which is half of what these keys do. There is now a `tab_id` beside the `tab`: one is where it sits in the strip, the other is what you pass back. And three more rows overflowed the cheatsheet: `modal_frame` clamps the box to the screen, so at 56x44 the `session` group was simply not drawn. The sheet now says `+7 more, in docs/keys.md` when it cannot show everything -- counted by walking the rows it skipped, because arithmetic on two uneven columns claimed six were hidden on a screen showing all of them. A list that quietly stops is worse than a short one: the reader cannot tell that the key they wanted was on it. Reflowing to two columns when the height forces it would be better than a footnote, and is not this commit. Verified: `tests/test_move_pane.py` at 38 checks -- the keys in both directions, breaking out, the refusal when there is only one tab, pushing the last pane out and landing in the survivor, and that the sheet prints `>` rather than the bare period it also answers to while the palette finds it by label. `tests/test_help.py` gained the short-terminal case and lost an assumption about its own fixture height.
* sl0ppty: the prompt speaks config, not a dialect of ituser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> include "contrib/chrome/heartbeat.kdl" heartbeat.kdl: 1 chrome, 0 content chrome> shaders { ...> tint where="chrome" color="#ff5fd7" amount="abs(sin(t / 5))" ...> dim where="content" amount=80 ...> } 1 chrome, 1 content Both of those were errors an hour ago, and the second one was my fault twice over: `:paste` printed `include "..."` and printing something you cannot type back is a prompt lying about its own output. Pasting it answered `unexpected argument for include`, and `load "..."` answered the same about `load`. So the grammar is now the config's, all of it: an entry, several with `;`, an entry with its own `where=`, a whole `shaders { }` block over as many lines as it takes, `include "f.kdl"`, `load f`. There is a test that reads `:paste`'s block off the screen, clears the pane, types it back line by line and compares the cells -- round-tripping is the property, not a nice-to-have. **The text is a document.** What it says replaces both chains, exactly as naming `shaders { }` in a config replaces the block rather than adding to it, and an entry's `where=` decides which rect it lands on. That drops a rule I invented last week -- "one chain, one rect", which refused an entry that named the other one -- because it made the prompt's mode a law rather than a default and made a two-rect block, the thing `:paste` prints, impossible to paste back. The prompt now keeps the document and sends all of it, so a line aimed at the frame leaves the contents alone, and the reply says `1 chrome, 1 content` rather than `ok`: with routing visible, the counts are the answer worth reading. `route_entries` is the one place routing is decided now -- config file, preset file, typed line, same function. `config_parse_chain_doc` takes the wrapper or the bare entries, because both are things a config contains and therefore both are things somebody will paste. Still nothing in the prompt parses KDL. A file goes by path; a pasted block goes via a temporary file, since an OSC payload cannot carry a newline -- a control byte ends the sequence, by the spec -- and joining lines with `;` would need exactly the comment-and-quote scanner I refused to write. The only syntax the script looks at is counting braces to know whether you have finished typing, and an empty line gives up on that. One hint, for the mistake that started this: when the session says `unknown shader`, the prompt adds `(a file? :load <path>, or include "<path>")`. After the fact rather than before, so a plugin's shader name is never second-guessed. Verified: `tests/test_shader_repl.py` is 41 checks -- every input shape, the multi-line paste and giving up on one, the round trip, the hint -- and `tests/test_inband_shaders.py` gained the document rule in both directions, including that a document naming only the frame takes the contents pass off. Full clean suite green.
* sl0ppty: `:load` a preset, without a second KDL readeruser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> :load sine-comet sine-comet.kdl: 1 chrome, 0 content chrome> :paste // sine-comet.kdl include "/home/user/dev/sl0ppty/contrib/chrome/sine-comet.kdl" A path, or the bare name of anything in `chrome/` or `shaders/` beside the script, from any directory. Entries land wherever their own `where=` says, so one file can fill both chains and a preset goes where its author put it. Tab completes filenames after `:load` and vocabulary everywhere else. **The prompt does not parse the file.** I started writing the scanner -- find the `shaders {` block, count braces, strip `//` outside quoted strings so that `color="#ff0033"` and `t / 8` survive -- and stopped, because that is a second reader of a format that already has one, and the two would disagree exactly where comments meet strings. Instead the session gets the path and reads it with the parser it already has: `config_parse_chain_file`, built on the same `parse_shader_entry` a config file goes through. Anything that is not a top-level `shaders { }` block is ignored, so a whole `config.kdl` can be handed over and only its shaders apply. The alternative was regenerating all 45 presets into something easier to read from Python. That would have cost them what makes them useful: each one is a config fragment today -- pasteable, `include`-able, checkable with `sl0ppty --check`, and loaded as a config by `tests/test_chrome_presets.py`. A format only the prompt can read would be a fourth thing to keep in step. `:paste` after a load answers with an `include` line rather than the file's thirty: the file is on disk, it is already the block, and reprinting it scrolled the pane you were looking at -- which is how I noticed. Both chains are built beside the live ones and swapped in together, so a file that goes wrong half way leaves the pane as it was rather than wearing the half that parsed. Each compiled expression is matched to the chain whose shader points at it before either is installed; freeing a program the other chain still uses is the one bug here that would not appear until the next repaint. Verified: nine checks in `tests/test_shader_repl.py` -- a preset by bare name, a file with both rects going to both chains, the three ways to be wrong (no such file, no `shaders { }` block, no argument), what `:paste` says afterwards, and tab completion. Then all 45 shipped presets loaded by name in one session: none refused.
* sl0ppty: three ways back from a painted paneuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;\033\\' # from the program sl0ppty cmd '{"cmd":"clear-shaders","id":0}' # from outside it bind "z" "clear-shaders" # from the keyboard `app_clear_pane_shaders` empties both of a pane's chains and says whether there was anything there, which is what lets a key answer "shaders cleared" or "no shaders on this pane" instead of maybe having done something. In-band it is not a new verb: naming no rect means all of them. `shader;` sits under `shader;chrome;` and `shader;content;` as the degenerate case of "which rect", so a program puts a pane back in one exchange and cannot leave it half done. `contrib/shader-repl`'s `:both` now uses that rather than two. **None of the three is gated on `in_band_shaders`.** A chain outlives the setting that allowed it -- paint a pane, then turn the setting off, and the paint is still there, because a chain belongs to the pane and not to the file (there is a test that reloads with it off and clears anyway). A way out that the setting can take away is not a way out. The operator path matters for the same reason: the program that made a pane unreadable is not always in a state to put it back. They clear what the *pane* set. The config's chains and the session's own dimming are not this pane's doing and are derived every frame anyway. The action is bound to nothing, like `scroll-up`: it is in the palette, which is what the palette is for, and the docs say to bind it if you leave the setting on. Which is where a second bug turned up -- the palette truncates a label at 26 characters, so "undo this pane's own shaders" read as "undo this pane's own shade" on screen. Renamed to fit, and `tests/test_palette.py` now checks every label against the column, since nothing failed for that: it just looked like a typo in front of someone.
* sl0ppty: a prompt with a memoryuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `contrib/shader-repl` imports readline, which is most of it: editing, up/down, ctrl-r, and history kept between runs in `$XDG_DATA_HOME/sl0ppty/shader-repl.history`. Chains only -- a `:` command is taken back out of the history it would otherwise fill, because pressing up four times past `:quit` and `:paste` to reach the line you were working on is the annoyance history exists to remove. Tab completes the commands, the nine shader names, the property keys, the two rects, the three channels, and the expression language's own variables and functions. `:help` prints the lot at once, which is the same list from the same place. That list is a claim about what exists, so `tests/test_shader_repl.py` checks it against `src/shader.c` and `src/expr.c` and fails if either grows a name it does not have -- verified by removing `spotlight` and watching it fail. Then it types every name it offers at a real prompt and requires `ok` from a real session, because a name that completes and does not parse is a lie with a keystroke attached. History across two runs, a tab completion, and `:help` are driven the same way, through a pty. Two things that cost me time and are now written down where they bite: keys go into a pane escaped (`\r`, `\t`, `\e[A`) because `raw` takes the rest of one command line and a literal newline ends the command instead of arriving; and Enter is CR, which is what a terminal sends and what readline waits for. Also: **a bare argument on a shader entry is now a complaint.** Every parameter is a property, so `tint color="#000000" spotlight` is not a pass with a spare word, it is a missing `;` -- KDL reads one node, the second name does nothing, and the session used to answer `ok`. It now says `unexpected argument for tint: spotlight (separate entries with ';')`. Found by watching the prompt get used.
* sl0ppty: a pane can set its own shadersuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"\033\\' The field after `shader` is which rect -- `content` or `chrome` -- and the rest of the payload is a chain in exactly the syntax a `shaders { }` block uses, taken verbatim so that `;` can go on separating entries. Empty text clears that chain. The session answers on the program's stdin, `shader-reply;ok` or `shader-reply;error;bad amount for tint: ...`, because a prototyping loop that gets no answer cannot tell a typo from a build without the feature. `contrib/shader-repl` is the prompt this exists for: type a chain, watch the pane, and `:paste` prints what you have as a config block. Both ends are the same parser, so what you arrive at is what you can keep -- verified by pasting `:paste`'s output into a file, linting it, and checking the frame comes out the same colour. **D13 said this was impossible on purpose, and the reasoning stands**: a program that can restyle the session it happens to be running in is a hazard, and `cat hostile.txt` should not dim your panes. But *impossible* was the wrong shape for it -- editing a file, saving, looking and guessing again is slow enough that shaders go unwritten. So it takes `in_band_shaders true`, one line of consent, and it is scoped to the pane that asked: its own two chains, never its neighbour's, never what the config said about anyone else. There is a test for the neighbour. Each chain owns the programs its expressions compiled to, and is swapped in whole, so a chain that turns out not to read leaves the pane looking like it did rather than half-restyled. Measured: 1,800 replacements of an animated chain, resident size flat after the first pass. Also here, because it is the same paint order: the chrome chain now sits between the config's and policy's, exactly where the content chain does. Dead code out -- `app_shade_add`/`app_shade_clear`/`app_shade_count` had no callers and their comment claimed the opposite of what this commit does.
* sl0ppty: one shader entry, one parseruser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | `parse_shader_entry` is the whole of "what does `dim amount=90 where="chrome" channel="fg"` mean", lifted out of the loop that reads a `shaders { }` block. The block parser calls it and files complaints; a new `config_parse_chain` calls it for a chain that came as text rather than as a file, and hands the compiled expressions back to whoever asked. Nothing behaves differently. It is a refactor for the next commit, which lets a program in a pane set that pane's chrome chain: the reason to do it this way is that a chain typed at a running pane and a chain in a config file must mean the same thing, or prototyping teaches you something that then doesn't work when you paste it. Two details the split had to settle. Expression ownership is now the caller's -- the config keeps owning the programs a file produced, freed on reload, and a chain parsed for someone else comes back with its programs listed so that someone can free them on their own schedule. And a bad colour no longer drops the pass: it complains and runs at the default, because a shader at the wrong colour is visible and can be corrected, while a shader that isn't there looks like a shader that didn't work. One chain is one rect. The caller says which, and that is the *default* an entry's `where=` starts from, so a line lifted out of a config still means what it meant there -- while a line naming the other rect is refused rather than quietly moved to the one you asked about.
* sl0ppty: padding, per sideuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | padding 1 // every side padding 0 2 // vertical, horizontal padding 2 0 0 1 // top, right, bottom, left One value, two, or four in CSS order, because that is the order anybody who has written a stylesheet already knows. `pad` becomes four fields and the content rect is deflated per side rather than symmetrically, which is the only real change: everything downstream already worked off `n->content`. The units do not change with how many numbers you write. They are ROWS, like `gap`, and a horizontal one is multiplied by `gap_aspect` on the way out -- so `padding 1`, `padding 1 1` and `padding 1 1 1 1` are one padding rather than three, and a ring that looks square is still one number rather than an arithmetic problem. Anyone who wants plain cells sets `gap_aspect 1` and gets them everywhere, which is what that knob is for. Three values is refused with a line saying what it wanted. CSS reads it as top/horizontal/bottom, and a line whose meaning you have to look up is a line nobody can read -- and `sl0ppty --check` now has something to say about it, which is the difference between refusing and ignoring. The dump writes the shortest form that means what is in force, so `padding 0` stays `padding 0` and a dump of a dump is still the same dump. Checked by measuring both rects: a pane's insets from its own frame, four numbers, for every form -- including that `gap_aspect 1` turns the horizontal ones into cells, and that a refused padding leaves the rest of the file applied.
* sl0ppty: every complaint, and the chords that produced eleven of themuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "C-a is not a valid prefix in my config.kdl", which it was not, and neither were ten other lines of the same file -- a file written by `sl0ppty --dump-config`. The loader now collects every complaint instead of keeping the first. Each one carries the file and the line it happened at, so "line 14: bad binding" became "config.kdl:116: bad key: ←", and a complaint from an included file names that file. `err` still means the first message, so a session's one status line behaves as before; the list is what a linter needs, and `--check` (next commit) is that linter. Both changes live in the same regions of config.c, which is why they are one commit. Then what the list found. `--dump-config` is documented as "a file you could have written", and `--dump-config > ~/.config/sl0ppty/config.kdl` as a supported way to start one. It wrote every chord in the *cheatsheet's* notation: prefix "C-a" bind "?" "help" bind "←" "focus-left" bind "S-tab" "prev-tab" and the parser only spoke the config's, so eleven of the file's own lines came back as complaints and the session ran on the defaults those lines were describing. Two sessions looked identical for the worst possible reason, which is why the test asserting exactly that never noticed. Both halves were wrong, so both are fixed. The parser accepts what the sheet prints: `C-` `M-` `S-` as well as `ctrl+ alt+ shift+`, the arrow glyphs as well as `left`/`right`, and a character that carries its own shift -- `?` is shift+slash, `H` is shift+h, `|` is shift+backslash. config.kdl has claimed for months that `?` and `S-slash` were both writable; only one of them was. A punctuation binding registers *both* encodings, because whether a terminal reports `?` as shift+slash or as a bare byte is up to the terminal and not to what the config meant. The shipped default has bound `?` twice by hand for exactly this reason since it was written; this is that, done once, in the one place that knows the character implied the shift. A capital letter needs no such thing -- the decoder reports `H` as shift+h everywhere. And the dumper writes the canonical config spelling: `ctrl+a`, `shift+slash`, `left`, `shift+tab`. `shift+` spelled out rather than folded back into `?` or `H`, because both are accepted on the way in and one spelling on the way out keeps a dump of a dump the same dump. A key with no spelling the parser would accept is written as an empty chord rather than as its display form: writing the display form is what caused this, and a dump that does not load is worse than a dump missing a line. The test that should have caught it now does: a session started from the dump is asked to reload and must have nothing to say. Looking at the screen was never going to find this, because the screen was right.
* sl0ppty: marks a font actually draws at cell sizeuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ▬ □ ✕ away, bigger, gone ▬ ■ ✕ ...and while it is already filling the tab The boxed operators of the commit before this (⊞ ⊟ ⊡ ⊠) were one tidy family and unreadable with it. They are *math* symbols, sized to sit beside x and y in an equation, so at a terminal's cell size the interior that carried the whole meaning -- the minus, the plus, the dot -- was two or three pixels of it. Four identical smudges. Geometric shapes and dingbats are drawn at full cell size, which is what makes them readable at all, so legibility wins over uniformity here: four different shapes, one verb each. Picking glyphs that fill their cell is also the answer to the evenness that put plain ASCII here originally -- a glyph that does not fill its cell hands the slack to the gap beside it, by an amount the font decides. A bar for minimise rather than the obvious ▼, because the split guide already owns all four triangles (◄ ► ▲ ▼) and the scroll indicator owns ▲ again. A glyph that means "split downward" on the border of a frame cannot also mean "put this pane away" in its corner. Found by the guide's own test failing on the first attempt, which is exactly what that test is for -- and the reason it caught a UI collision rather than a typo is that it asserts only *one* arrow is on screen at a time. Widths checked in a real frame again: the rule still closes on its corner, each button is still measured rather than booked, and the hits land on the glyphs.
* sl0ppty: one family of marks for the frame's buttonsuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ⊟ ⊞ ⊠ away, bigger, gone ⊟ ⊡ ⊠ ...and while it is already filling the tab `_ # x` said what they did only if you already knew, and `*` for a zoomed pane was the weakest of the four: nothing about an asterisk says "put it back". So there are four marks now instead of three and a half, with the unzoom state getting one of its own. ASCII was there for a reason, and the reason survives: the cells are even, but a glyph that does not fill its own cell hands the slack to the gap beside it, and how much it hands over depends on the font -- which is why box-drawing and block characters were tried here and looked unevenly spaced. Four copies of *the same box* answer that better than four unrelated glyphs did: whatever a font does with the box, it does four times, and only the interior changes. Minus for away, plus for bigger, dot for back to normal, times for gone. Single width, checked in a real frame rather than assumed -- the row still closes on its corner and each button is still measured rather than booked, so the hits land where the glyphs are. Not emoji, which many terminals draw two columns wide while chrome here is booked as one. Two tests were pinning the old glyphs by hand (`line.rindex("x")` for the cheatsheet's close button, `"*" in line` for the zoom state). They read them out of `sl0ppty --dump-config` now, which is generated from the code: picking a nicer mark later cannot leave a test asserting a glyph nothing draws.
* sl0ppty: say what the config could not honouruser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A config that half-applied said nothing. An unknown shader, a binding that does not parse, an include that is not there: the file loaded, that line did not, and the session announced "config reloaded" as though nothing had happened. Every one of those messages was already being produced -- config_load has filled `err` on a successful load since it was written -- and then dropped on the floor by every caller, because a non-zero return meant "fine". So the complaint from the last load is kept, and the front end says it where it would have said "config reloaded". Once at startup too: a config that loaded with a complaint still started the session, and the stderr line goes where nobody attached can read it. The `reload` control verb answers `{"ok":true,"warning":"..."}` -- ok, because the file did apply, with the reason attached for a script that cares. Two things fell out of writing the test for it. An include's complaint was reported by nobody when the file in the middle was fine. apply_includes only looked at the return value, so a theme that loaded while *its* include was missing swallowed the message: the child returned true, and true meant there was nothing to say. The complaint now travels up whether or not the include itself loaded. And a complaint now carries the name of the file it happened in, exactly once -- added by the file that produced it, never by the files that included it, because prefixing at every level builds a chain that truncates before the part that matters. So: t.kdl: unknown shader: bloom keys.kdl: line 2: unknown action fly b.kdl: includes nested too deep (a cycle?) cannot open /home/you/.config/sl0ppty/themes/nope.kdl which is enough to know which file to open, in a message that fits in a toast.
* sl0ppty: watch what the config was built from, not just what it was calleduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An included theme that does not reload when you save it is half a feature, and the half that is missing is the one the file headers in contrib/themes have been promising all along ("a running session re-reads it the moment you save"). That was true while a theme was something you pasted; the moment it became something you include, the watch was on the wrong file. So a config now remembers every file it was read from -- itself and everything it included -- and the watcher watches all of them. Recorded before each parse rather than after, because a file that is not there yet is exactly the file worth watching: writing it is the next thing that will happen, and that is the same reason the watch is on directories rather than on files. Deduplicated, so a cycle does not fill the list with two names twice. The set is rebuilt after every successful load, because it is a property of the config that just loaded: an include can be added, pointed somewhere else, or taken out, and the watch has to follow. One inotify watch per directory, deduplicated by the descriptor inotify hands back for a directory it is already watching; names are matched against the whole set rather than against the directory whose watch fired, which means two directories holding a file of the same name reload for each other's save. That costs a debounced re-read of a config that has not changed, and is not worth a second data structure to avoid. Only the main config's directory is created if missing. An include's is not: a mistyped path should not leave a directory behind. tests/test_reload.py drives a real session for this one -- the watcher is the thing under test and the headless driver has none. It writes a config that includes a theme, checks the theme applied at startup, then edits *only the included file* and waits for the session to change colour.
* sl0ppty: a config can be made of filesuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | include "themes/amber.kdl" include "keys/vim.kdl" "shaders/crt.kdl" which is how you switch a theme by editing one line rather than pasting one in. Nothing about it is theme-specific: an include is another config file applied here, so the same line composes keys, shader chains, states or geometry, and contrib/themes stops being a directory you copy out of. A relative path is relative to *the file doing the including*, never to the directory the session was started from -- a config is a thing on disk that refers to its neighbours, and where you were standing is not part of what it means. A leading `~` is a home directory, an absolute path is itself, and an included file may include others. What you include is the base: the file with the `include` line in it wins wherever they disagree, and a later include wins over an earlier one. That holds wherever the line sits, because the loader reads a document by asking it for the keys it knows rather than walking it top to bottom -- so "here" is not a position it could honour, and "the file you are editing wins" is the rule worth having anyway. It is what makes a theme something you can put two lines on top of. A depth limit rather than a set of files already seen, because the two failures it has to stop are the same failure: a config that includes itself and a config eleven files deep are both a mistake, and the message says which one you have. A file that will not load is skipped and the rest of the config still applies (D9) -- losing your keybindings over a mistyped theme name would be the worse answer. The loader was already shaped for this: `config_load` applied one parsed root onto a config_t, so it becomes a recursive `load_into` that applies its includes first and then itself. Errors from an include get their own buffer, so a broken theme cannot overwrite a message the file that included it has already produced. tests/test_include.py covers the two rules that carry the weight -- the outer file winning either way round, and a nested include resolving against the file that wrote it -- plus multiple files on one line, `~`, a binding from an included file, a missing file, a cycle, switching theme through a reload, and every file in contrib/themes applying as an include, checked against each file's own frame_focus so it cannot pass by looking default. Not yet: an included file is not watched, so editing the theme itself does not reload the session. Editing the include line does.
* sl0ppty: shaders on the chrome, and two keys for the arrangementuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's frame -- border, padding, title, buttons -- instead of its contents. Nothing else changes: same registry, same `amount`, same expressions, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than four passes over four sides: an effect has to be able to travel round a border, and four passes would each count x/y from zero and restart at every corner. The hole keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our guess at that default and mixed towards the colour -- a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state -- a transition is the one thing a frame cannot tell you, so it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. A turn is refused when it would not fit: a share of the height is not always a share the width can afford, and a node that cannot give its children their floor takes the whole tab down to a list. So the turn is tried, the layout is asked what it made of it, and one that flattened the tab is put back with a word. Same rule `split_fits` applies to splitting. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
* sl0ppty: a command palette, on a key you chooseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every action by name, without having to know its key. The cheatsheet answers "what is the key for this?" and then needs you to press it; this answers "just do the thing", which is the question you have when you cannot remember there was a key at all. `C-a shift+p` by default, and `bind "<key>" "palette"` for anything else. It lists actions that are bound to nothing, which the cheatsheet by design cannot -- that sheet is built from the binds, so an action with no key does not appear on it, and that is exactly the action you have no other way to reach. Two of ours are in that state today (scroll by a line, either direction). Where a binding does exist the palette shows it beside the row, so using the palette teaches the key that would have skipped it. Where several exist it shows them all: a config's binds add to the defaults rather than replacing them, so naming only the first would hide the one the user actually chose. Rows match on the phrase, the group, and the name a config file uses, so "split into columns", "panes" and "split-cols" all find the same thing depending on whether you have been editing the config lately. The finder and the palette are now one picker with a subject rather than two machines that would drift -- the same modal, query, filtered list, selection, keys and mouse rules, differing only in what fills the rows and what Enter does with one. Choosing goes through the row's own action string, so the keyboard and the mouse cannot take different paths to the same row. The finder's 55 checks pass unchanged across the refactor. Left out on purpose: `literal-prefix`, which means "type this key" rather than "do this thing" and picked from a list would send the prefix to a pane hidden behind that list; and `select-tab-N`, which is positional, so a row reading "go to that tab" cannot say which. Running is by action rather than by synthesising the keystroke that would have caused it -- a palette that had to invent input to do its job would be lying to the rest of the program about what happened. That meant splitting prefix_command in two, which it wanted anyway: the lookup and the doing were only ever together by accident.
* 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: status_pad defaults to 4user2026-08-14
| | | | | | | | | | | | | | Wider than the panes' own margin (gap * gap_aspect, 2 at the defaults) rather than equal to it, so the strip and the line read as chrome sitting outside the layout instead of as another row of it. Picked by looking at 2, 3, 4 and 6 on a real screen; the difference between adjacent values is one column and is not something to choose from a config file. One test had an opinion about where the tab strip starts. It sampled a style at column 3 to prove the shader pass leaves the strip alone, which was true only because the strip happened to begin there; it now finds the strip's first painted column and asks about that. The assertion was never about column 3 and should not have said so.
* 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: 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.