aboutsummaryrefslogtreecommitdiffstats
path: root/Makefile (follow)
Commit message (Collapse)AuthorAge
* theme: a name and a directory, not sixty colours in your configuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Wearing a theme meant pasting its `theme { }` block into config.kdl, or including a path to a file you had copied there yourself. Both make the palette a literal in your config, and a literal is the one thing you cannot change cheaply: not from a script, not from a running session, not by a list of what is available, and never again from upstream -- the colours you pasted are the colours that theme had the day you pasted them. So a theme is a file in a directory now, and a config names it: theme_name "phosphor" theme { frame_focus "#00ff88" } // ...but that one colour is mine theme_name is applied after the includes and before everything the file says itself, which is the rule includes already follow and for the same reason: the loader reads a document by asking it for names rather than walking it in order, so "here" is not a position it could honour, and "what you asked for is the base, what you wrote is yours" is the rule worth having. Two directories, yours first: `themes/` beside the root config -- beside the file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed to --check) takes its themes with it -- and then <prefix>/share/slosh/themes, compiled in from PREFIX. install(1) has been putting themes there all along and nothing could read them; now a shipped theme is nameable without being copied, and a file of yours with the same name shadows it, which is how a root-owned theme gets edited. A name, not a path. The directory is what makes a theme switchable, listable and shadowable, and a path silently does none of those, so `theme_name "../x.kdl"` is refused with a line pointing at `include`, which still works and is still the answer for a file somewhere a directory of names cannot reach. A theme that is not there is one complaint and no more (D9): the rest of the config applies, because losing your keybindings over a mistyped colour scheme would be the wrong trade. A theme file is read by the same loader, so it is watched like any other: saving one reloads the session wearing it, and the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours exactly as before. Switching without editing anything, over the socket a session already has: slosh -s main cmd theme # amber default* ... slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' The session holds the name over later reloads, so saving your config for an unrelated reason does not put the old colours back; `save` writes the theme_name line into the file, replacing the one already there and leaving every other line and comment alone -- dumping the whole config back over itself would reformat a hand-written file and drop its comments, which is not a fair price for changing a colour scheme. A name is tried on a throwaway config first, so a typo costs nothing. An empty name gives the answer back to the file. --dump-config stops writing colours: it writes the theme's name and the colours a config overrode by hand, so a dumped config keeps following its theme instead of freezing it. --dump-theme is the other half and is new: the resolved palette as a theme file, which is how you start one from the session in front of you rather than from an empty file. It is also what keeps contrib/themes/default.kdl honest -- that file is now the annotated reference (the commented palette moved there out of config.kdl, where it was 119 lines of colour in the middle of the prose), and a test compares it against the compiled-in one. While in there: tests/test_themes.py's "every theme sets every colour" guard had been grepping for `&c->`, the spelling before the colour table moved to offsetof, so it had been finding zero colours and passing for months. Fixed, with the derived and opt-in colours exempted for the reasons they are derived and opt-in, and with a check that the guard found a table at all.
* build: a vendor bump rebuilds libghostty-vt, everywhere it is consumeduser2026-09-10
| | | | | | | | | | | | | | | | | | | | | Three stale-build landmines, all found by shipping v0.1.7 -- the first release where new code (GhosttySearch) actually needed the vendor bump of 2026-09-01: - $(VT_LIB) had no prerequisites, so an existing .a *was* the build: the macOS builder compiled pane.c against zig-out headers from before the bump. The vendor manifest is now the prerequisite; a re-vendor rewrites it, so a bump rebuilds the lib and its installed headers. - web/demo had the same guard and also never passed -Dversion-string: upstream's build.zig ran git describe in our repo, and on the tag checkout web/publish builds from, a slosh tag is a fatal version mismatch. Both fixed; both had hidden behind a stale build/vt. - The builder's zig-pkg (excluded from the sync, and left half-empty by a once-interrupted push) is an input, not a cache: it is now synced separately and additively, so a bump's new packages arrive and a partial local tree cannot maim the builder's copy.
* slosh --install-terminfo: the binary carries its own terminfo entryuser2026-09-02
| | | | | | | | | | | | The xterm-256color fallback works everywhere; the real entry is better, and a tarball user on a bare box had no easy way to get it. The entry is generated from the vendored tree (contrib/terminfo, so it describes the terminal core we actually ship), compiled with tic, embedded as bytes, and written to ~/.terminfo under every name it answers to, letter and Darwin-hashed layouts both. No root, no package, no conflict with a ghostty installed later. Proven end to end in a mount namespace: bare box falls back, installs, and the next pane says xterm-ghostty.
* Add competitive multiplayer support.Ingmar Steen2026-09-02
| | | | Last input wins, canvas size is adjusted to active connection.
* shaders: remove the .so plugin loaderuser2026-09-02
| | | | | | | | | | | | The static musl release builds cannot dlopen -- musl compiles it out -- so loadable shader plugins were a feature most shipped binaries could not use. Nothing we ship used it either: every shader in contrib/shaders is an expression. Gone: the dlopen/glob loader in shader.c, shader_dir in the config, the plugin ABI table and entry macro, the Windows dl*/glob shims that existed only for it, the contrib/shader-plugin skeleton, its tests, and every doc mention. shader_abi.h stays as the shader model (cell_t, shade_ctx_t, shader_t) with a note about what it used to be.
* web: the real site is the only site, and web/publish ships ituser2026-09-02
| | | | | | | | The coming-soon gate (FULL=1) and its page go: the site is finished enough to be the default. web/publish (make www-publish) builds, refuses to sync a tree that does not look like the site, and rsyncs to /opt/slosh -- around /arch, which contrib/ship owns, and around the served demo when a NO_DEMO build has none to offer.
* find zig on PATH instead of hardcoding ~/zig-0.16.0/ziguser2026-09-02
|
* docs: deprecate boring design narrative, more trimming and cullingPeter Geissler2026-09-02
|
* graphics: a frame the client never got must be said againuser2026-08-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane image is re-emitted to the client as decoded RGBA, which for one screenshot is ~4MB of base64 in a single indivisible message -- and conn_send silently dropped anything that would overflow the 4MB outbox, while push_graphics ignored the refusal and the model advanced anyway. Everything stateful in that dropped frame was lost for good: the image was marked sent (so its placements drew nothing, forever), and the deletions rode out with it (so a placement the client had was never revoked). Reported as screenshots in a recovered pi transcript parked at a fixed spot on the screen, immune to scrolling and pane swaps -- those were placements whose deletion left with a dropped frame. The 1000x800 one could never be delivered at all: its transmission alone was bigger than the cap. So the frame is now transactional. Deletions queue and are repeated every flush until gfx_commit() confirms the client heard them; transmissions recorded in a frame are unwound by an undelivered commit so they are sent again. A flush nobody commits counts as undelivered at the next gfx_begin(), which is what the `graphics` control command is: bytes rendered for a CLI, not the client. The server reports the verdict and keeps the paint pending until it lands. The outbox cap now bounds the backlog rather than backlog-plus-message, so one big transmission on a drained queue always fits -- and that surfaced a second way to lose: a queued image legitimately holds the backlog over the cap until the client swallows it, and push_frame read that as a dead client and closed the connection. The session auto-detached every time a screenshot crossed the wire. A full outbox is a client that is *behind*, not gone: push_frame now marks the screen for a full repaint (the dropped diff means the client's cells are unknown) and retries as POLLOUT drains the queue; a client that truly left still shows up as a write error there. The headless suite pins the owed-until-committed contract; the new tests/live_graphics.py drives the real socket with a bigger-than-the-cap image and a reader that stalls, and would have caught both bugs: the flood must not detach, the transmission must arrive whole, and a placement scrolled away must be deleted -- then come back on scroll without a second copy of the pixels.
* release: one command to bump, commit and taguser2026-08-28
| | | | | | | | | | | | | | contrib/release X.Y.Z moves ./VERSION, commits and cuts the annotated tag together, so the floor and the tag cannot disagree by construction. It refuses a dirty tree, a detached HEAD, a malformed or non-increasing version, and an existing tag; notes passed as the second argument land in the tag message. It prints the push and `make release` commands instead of running them, because publishing should be deliberate. contrib/release --check verifies the invariant (VERSION equals the newest tag) on its own: `make release` runs it before building anything, and the new pre-push hook runs the same comparison against the commit a v* tag points at, so a tag made by hand is caught before it escapes the machine.
* gitignore: DESIGN.md and .fastfeedback are local, not the projectuser2026-08-28
| | | | | | | | DESIGN.md was a scratch design doc and .fastfeedback a per-repo tool cache; neither belongs in the tree. Both stay on disk, ignored. Everything that pointed at DESIGN.md now points at docs/design.md, and the decision counts quoted from it (which had already drifted: the README said twenty-one, the docs said twenty-two) are gone with it.
* windows release: a zip in dist/, built beside the linux tarballsuser2026-08-28
| | | | | | | | | make -f Makefile.windows dist stages slosh.exe as slosh-<version>-windows-<arch>.zip -- a zip, not a tarball, because Windows is the machine that will open it -- carrying the same licensing paperwork as the linux tarballs, with docs/windows.md riding along instead of the manpages. `make release` now builds both architectures via release-windows, so the windows zips land under the same SHA256SUMS as everything else.
* path: unit tests for the two spellingsPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | The functions the last two commits fixed are only interesting on the platform the suite does not run on, which is how they came to be wrong in the first place. So the Windows cases are asserted under #ifdef and the POSIX ones assert the opposite: that a drive letter is an ordinary name here, that a backslash is an ordinary character, that neither rule leaked across. That second half is the one worth having. Both fixes are guarded, and a guard is exactly the kind of thing a later edit removes without meaning to -- `\` splitting a POSIX path would invent a directory out of a file called `a\b`, and nothing else in the suite would notice. Pure functions, so it joins the C unit tests rather than driving a session. Run on the Windows box as well, cross-compiled: 28 checks there, 20 here, the difference being the cases each platform has.
* vendor build: pass ghostty its own version, not our tagsv0.1.0user2026-08-27
| | | | | | | | | | | | | | | The vendored tree has no .git, so upstream's version detection runs git describe in *our* repository -- and the moment this repo grew its first release tag (v0.1.0), ghostty's build refused it: 'tagged releases must be in vX.Y.Z format matching build.zig'. Our v0.1.0 is not ghostty's 1.3.2-dev, and upstream is right not to let a stranger's tag name its build. -Dversion-string is upstream's own escape hatch for exactly this. The value is read from the vendored build.zig.zon rather than written here, so a re-vendor updates it in the one place it already lives. Both invocations (the host vendor target and the cross-built release legs) get it through one VT_FLAGS.
* slosh(5): the file formats get their manpage; slosh(1) learns shadersuser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Section 5 is where unix keeps its file formats, and slosh has two documents sharing one syntax -- the configuration and the layouts -- so they share a page the way they share a parser. docs/slosh.5 covers the KDL subset, where the file lives and how reload refuses a broken one, include and its precedence, every top-level setting grouped the way config.md groups them, the theme block, the shader vocabulary with the expression language summarised, the states and their ranking, the keys block including direct bindings, and the layout schema (tab, pane, and their properties). Held to the source like slosh.1 before it: every name in config.c's KNOWN_TOP -- the list the loader refuses unknown settings against, itself held to the loader by test_config.py -- must appear in the page, so a knob cannot be added without this page hearing about it. The pair must cross-reference each other, and both must be pure ASCII and lint clean under groff -ww. slosh(1) grows a SHADERS section -- the one feature a synopsis reader would never guess a multiplexer had -- and its SEE ALSO now names slosh(5) and nothing else: the other multiplexers can advertise themselves. Install puts the page under share/man/man5, the release tarballs carry both pages, and the Homebrew formula installs what the commit it is building has, guarded as before.
* a manpage: slosh(1), installed, shipped, and held to the sourceuser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | The distributables were a bare binary and a directory of Markdown, which is fine until somebody types 'man slosh' -- the unix-traditional place to ask. docs/slosh.1 is hand-written man(7), pure ASCII roff, no toolchain: the synopsis, the options, the session commands, the default keys, the mouse (including the new drag-past-an-edge scrolling), where the configuration lives, what SLOSH_* means in a pane's environment, files, exit status. The prose docs stay the long form; the page says so and points at them. 'make install' puts it under PREFIX/share/man/man1 (and uninstall takes it away), the release tarballs carry it beside the LICENSE -- a tarball is what a packager starts from -- and the Homebrew formula installs it when the commit it is building has one, guarded because the formula must be able to build commits older than the page. Prose about a program drifts unless something holds them together, so test_docs.py holds the page to the source the way it already holds the site to docs/nav: every option main.c's parser compares against and every word command must appear in the page, every SLOSH_* variable the code reads or sets must be documented, the roff must be pure ASCII (it travels through implementations that never heard of preconv), and where groff exists the page must lint clean at CHECKSTYLE=3 with -ww. Adding a flag without documenting it is now a failing test, which is the only kind of documentation policy that survives contact with a deadline.
* release: one 'make release' for the three target binariesuser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | Streamline the release into a single target that stages all three binaries in dist/ with a SHA256SUMS: linux x86_64 } cross-built here with 'zig cc -target ... -s', static musl linux aarch64 } (runs on any distro), packaged as a .tar.gz with the binary + README + LICENSE + THIRD-PARTY-LICENSES macos arm64 handed to 'macos-dist', which signs/notarizes on the builder zig is already the compiler, so the two linux cross-builds need no extra toolchain -- just the vendored libghostty-vt rebuilt per -Dtarget. Verified both: static, stripped (~2MB), and both run (aarch64 under qemu-user). Factor the version ladder out of the Makefile into contrib/version, and call it from both Makefile and Makefile.macos, so the linux tarballs, the signed zip and the binary's own --version are all named from the same tag instead of Makefile.macos using its own 'git describe --always' scheme (which would have named the mac artifact after a bare commit hash while linux used the tag). make release-linux # just the two linux tarballs make release REF=<tag> # all three + SHA256SUMS
* version: derive from git tags instead of a hand-bumped constantuser2026-08-27
| | | | | | | | | | | | | | | | | | | VERSION was a constant in the Makefile that had to be bumped by hand and could silently disagree with the git tag a release was cut from. Derive it instead, with a committed ./VERSION as the floor and no-git fallback: on a tag v0.2.0 -> 0.2.0 past a tag v0.2.0-5-gabc123 -> 0.2.0-5-gabc123 (sorts after 0.2.0) before any tag 0.1.0-197-g2d1815 -> ./VERSION + commit count + short id no .git at all 0.1.0 -> ./VERSION verbatim (cgit snapshot -> Homebrew source build) A dirty tree appends -dirty. The commit count keeps 'brew upgrade' ordering monotonic before the first tag exists. version.h now bakes just VERSION (dropping the redundant +GITDESC, which the derived string already carries). contrib/brew-release reads the base from ./VERSION rather than grepping the Makefile's VERSION line, which is now a $(shell ...) expression it could not parse.
* macos: the pipeline is the project's, the identity is yoursPeter Geissler2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Makefile.macos was kept out of git because it named a team, a person and a mac. But almost all of it is mechanism -- build, sign, zip, notarize, staple, unlock a headless keychain -- and that is the project's, not the releaser's. Keeping the whole file out to protect four values meant the one machine that had it was the only place a release could be cut from, and that nobody could read how signing worked without being handed it. So the split is inverted. Makefile.macos, the builder doc and the scripts are tracked; macos-release.mk holds TEAM_ID, SIGNER and BUILDER and is the only thing gitignored. macos-release.mk.example is the template. The identity strings are now assembled from SIGNER and TEAM_ID rather than written out, which means an unconfigured checkout composes "Developer ID Application: ()" -- plausible, wrong, and diagnosed only by codesign a minute into a build. require-identity and require-builder catch it before anything runs and name the file to create; `help` prints "(unset)" rather than the empty shape. macos-remote-build now pushes macos-release.mk instead of Makefile.macos. That is the better arrangement anyway: the builder gets its pipeline from git at the commit being released, so a release is signed by the flow belonging to it and not by whatever the laptop was carrying. MACOS-BUILDER.md was in the same position -- local, and referenced by a file that is now tracked -- so it becomes docs/macos-release.md with the certificate name and notary key id replaced by their shapes.
* macos: sign and notarize on a mac that is not this onePeter Geissler2026-08-26
| | | | | | | | | | | | | | | | | | | | | | An unsigned mac binary is "damaged and can't be opened", so a release has to be signed with a Developer ID and notarized by Apple, and that needs a mac holding a private key. That mac is not the machine you develop on -- under `make all` it never is -- so `make macos-dist` builds nothing here: it syncs the tree to the builder over ssh, has it build, sign, notarize and staple, verifies the result there and fetches the zip back. The near end needs only ssh, rsync and a hasher, so it works from Linux. contrib/macos-builder-setup turns a bare mac into that builder and is re-runnable, with --check to say what is missing. The two things that make an unattended builder different from a laptop are handled rather than worked around: a keychain of its own, unlocked from a 0600 password file because a headless mac has no unlocked login keychain and codesign dies with "User interaction is not allowed"; and exactly one copy of the identity in the search list, because two make codesign ambiguous. No identity is in the repo. Who signs, which team, which mac -- that is Makefile.macos, which stays out of git along with dist/, and macos-dist tells you so when it is not there.
* make install: put the binary and the examples where they belongPeter Geissler2026-08-26
| | | | | | | | | | | There was no way to install slosh short of copying build/slosh by hand, which packagers cannot do and users should not have to. PREFIX and DESTDIR are honoured because that is the contract every package manager already expects; the Homebrew formula calls exactly this. Only the binary is needed at runtime. The config, layout, themes, chrome and shaders are examples you copy into ~/.config/slosh, so they go to share/slosh and nothing reads them from there.
* make vendor: no xcframework, a CLT-only xcodebuild is a stubuser2026-08-24
| | | | | | | | In lib-vt mode upstream's build.zig auto-enables an xcframework whenever xcodebuild is on PATH. On a Mac with only the Command Line Tools that xcodebuild fails, turning a working build into a broken one -- and we only ever consume libghostty-vt.a anyway. Pass -Demit-xcframework=false.
* fuzz: libFuzzer harness for all six parser surfacesuser2026-08-19
| | | | | | | | | | | | | Targets: kdl, jsonval, expr, proto, osc5577, input — each compiled standalone against its own .c with ASan + UBSan, no vendor deps. Includes seed corpora from shipped configs/themes/chrome, real control API lines, terminal escape sequences, and regression inputs from the bugs already fixed. standalone_main.c replays files without the fuzzer runtime for debugging and CI. Top-level: make fuzz (run all, TIME=15 default), make fuzz-corpus (replay seeds only, fast CI gate).
* slosh: the website lives in web/ -- one directory, one build, make wwwuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | Consolidate everything the website is made of: - web_new/ -> web/: the front page, install.sh, and build-site - contrib/webdemo/ -> web/demo/: the in-browser machine, committed for the first time (it had never been checked in) - make www at the top level assembles the whole site into build/www (NO_DEMO=1 skips the guest cross-compile) - web_plan.md deleted: the plan is fulfilled and web/README.md is now the record Path fallout: the overlay theme symlink gains one ../ (web/demo is a level shallower relative to contrib/themes), PYSRC in the Makefile follows the demo scripts, contrib/README.md drops its webdemo section. ROOT := ../.. in the demo Makefile survives unchanged -- web/demo sits at the same depth contrib/webdemo did. Verified: make www builds front page + docs + demo end to end, web/demo make check boots the image to slosh, tests/test_docs.py and make fmt-check green.
* 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: the make tools the last commit describeduser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The previous commit shipped the documentation for `make tools` and the `.gitignore` line for the venv it creates, and not the target itself. A `git reset --hard`, undoing a throwaway probe commit, took the working tree with it -- which is what `--hard` does. The `.gitignore` line was noticed missing and rewritten; the three files it was standing next to were not, so `docs/design.md` has been describing a target that did not exist. This is that target. `ruff` is not a system package on most machines, and `pip install --user ruff` is refused outright on a distro python (PEP 668): $ make tools ruff 0.16.3 in .venv survives make clean; rm -rf .venv to start over clang-format stays a system package, because everywhere it is one; `make tools` says so when it is missing rather than pretending it handled it. **The venv is looked in before `$PATH`, and that order is the point.** `ruff.toml` now pins the version with ruff's own `required-version`, so a ruff of the wrong one refuses to run and names both versions. Preferring `$PATH` would then strand somebody with 0.14 from their package manager and 0.16.3 sitting in the `.venv` the repo just built them -- refused by the tool, with the fix on disk and nothing looking at it. Tested with exactly that, for `make fmt-check` and for the hook. The pin exists because a formatter whose output moves between releases turns "formatted" into "formatted by whoever committed last", and the diff lands on the next person to touch the file. Bumping it is one line in `ruff.toml`, one in the Makefile, and a reformat commit -- the honest cost of a new version. The hook resolves ruff the same way, off `git rev-parse --show-toplevel` so it works from a subdirectory, and its message when ruff is missing now says `make tools`. Verified from a deleted `.venv`: `make fmt` stops with `ruff not found. run: make tools`; after `make tools` both `fmt` and `fmt-check` work with nothing on `$PATH`; `make fmt` over the whole tree changes nothing, so the pinned version agrees with what is committed; the hook formats a staged Python file through the venv, and still does with 0.14.0 ahead of it on `$PATH`. The hook was tested by running `.githooks/pre-commit` directly against a staged file. The obvious way -- a real probe commit -- needs the working tree clean to be meaningful, and getting there is what caused the mess this commit is repairing.
* sl0ppty: ruff formats the Python, and sorts its importsuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The other half of the pre-commit hook. C goes through clang-format, Python through ruff: `ruff check --select I --fix` to sort the imports, then `ruff format` to lay them out. `make fmt` does both, `make fmt-check` reports, `make hooks` installs it. ruff rather than black because it is one static binary instead of a Python dependency chain, and fast enough that a hook is free. Its formatter is black-compatible, so this is the ecosystem's formatting rather than a dialect -- measured at 9,935 changed lines against black's 9,953, which is the same answer. **Left entirely at the defaults**, which is the opposite of what `.clang-format` does. That file argues with LLVM in five places because the C here was hand-formatted into a house style worth keeping. The Python was not, and a formatter of this kind is only worth having when there is nothing left to argue about: every knob turned is an argument somebody has to have again later. 88 columns, double quotes, ruff's own line breaking. **Import sorting is on, and C include sorting stays off.** Not an inconsistency: a C include order can be load-bearing -- `_GNU_SOURCE` ahead of the system headers, and a grouping somebody wrote by hand -- while Python import order is not. Where it genuinely is, a statement between imports ends the block ruff will sort, and the test files lean on exactly that: import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from harness import Session, check, report `from harness import` cannot be hoisted above the line that makes it importable, because the `sys.path.insert` between them is a barrier. Verified before touching anything, since getting it wrong would have broken every test in the suite at once. Two details in the hook worth naming. `--select I` is passed on the command line as well as set in `ruff.toml`, so a stray `select` in somebody's own config cannot turn a formatting hook into a linter that refuses a commit over an unused variable. And the two tools are named together when either is missing, so setting up a machine installs both at once instead of finding the second one on the next commit. `set -e` and `[ -n "$c_files" ] && clang-format ...` do not mix: when the list is empty the AND-list returns non-zero and the shell exits, so a Python-only commit would have aborted with no message at all. They are `if` blocks. Found by testing the case rather than by reading it. Verified in a scratch clone, seven paths: Python alone, C alone, both in one commit, imports sorted with the `sys.path` barrier intact, a `contrib/` script that is Python with no extension on it, a partly staged Python file refused with `HEAD` unchanged, and a commit with no code in it drawing no comment.
* sl0ppty: clang-format, and a hook that runs it on what you stageduser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `make hooks` points `core.hooksPath` at `.githooks`, and from then on committing C formats it first, so what lands in git is already formatted. `make fmt` does the whole tree, `make fmt-check` only reports. The reformat itself is the next commit. **The config was chosen by measuring, not by taste.** LLVM is the base because it is what this code was already written in by hand -- two spaces, 80 columns, `char *p`, braces attached -- but LLVM alone rewrote 38% of the lines. Three disagreements account for most of that, and each is a real house idiom rather than an accident: - `if (!p) return false;` on one line, which is the guard idiom throughout. LLVM splits every one of them in two, two thirds of the churn on its own. `WithoutElse`, so an `if` that has an `else` still opens up: a one-line `if` hiding an `else` is the arrangement that hides a bug. - Comments are not reflowed. They are prose, wrapped by hand, and the breaks sometimes carry meaning -- a paragraph, a list, a diagram of a pane. A comment that runs long is a thing for a person to fix, not for a formatter to even out. - Include order is left alone. It is grouped by hand here and is not alphabetical within a group; sorting it would be churn at best and, at worst, a build that depends on an order nobody wrote down. Those three, plus one-line `case` labels and loops, bring the reformat from 38% to 17% -- and what is left is alignment and the hand-packed tables, which is the part a formatter is *for*. The vendored terminal core is excluded everywhere: `.githooks/pre-commit`, `make fmt` and `make fmt-check` all filter `^vendor/`. It is pinned by commit (D12) and reformatting it would turn every future re-vendor into a merge. **The hook refuses a partly staged file rather than widening the commit.** `clang-format -i` rewrites the working tree, so re-staging afterwards would commit work somebody deliberately left out with `git add -p`. It says which files and what to do instead. It also says when it formatted something, because a hook that edits your commit silently is a hook people learn to distrust, and it fails with the package name when `clang-format` is missing rather than letting unformatted C through the one check meant to stop it. `--no-verify` still escapes. Verified in a scratch clone, all four paths: a badly formatted file is committed formatted; a partly staged one is refused with `HEAD` unchanged and the index untouched; a commit with no C in it draws no comment; and `--no-verify` puts unformatted code in, which is what asking for it means.
* sl0ppty: [CC] src/app.cuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | The build printed twenty-two zig command lines of about 160 characters each, which is a screenful that says the same thing twenty-two times. Now it says which file: [CC] src/app.c ... [LD] build/sl0ppty `make V=1` prints the command lines instead. That is the whole reason for the flag rather than just adding `@`: the moment one of those lines is wrong -- a missing include path, a flag that is not doing what you thought -- the command line is the only thing worth looking at, and it should be one flag away rather than gone. `make help` says so, since that is where somebody will look for it. One label per kind, aligned with the ` ok test_x.py` the suite already prints, so a full `make test` reads as one column: [CC] an object, [LD] anything linked (the binary and the five test binaries), [SO] a shader plugin, [COV] the separate gcc coverage build. `make docs` drops its command line too and leaves the generator's own summary to speak.
* sl0ppty: make docsuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | contrib/gen-docs renders docs/ into a static site: one file of stdlib Python and one stylesheet, no toolchain, no package manager, no npm. Same argument as src/kdl.c being a hand-rolled KDL subset rather than a library -- the input is ours, we know exactly which constructs it uses, and a docs build that needs a lockfile is a docs build nobody runs. What the Markdown subset understands is what docs/ actually contains: headings (with anchors), paragraphs, fenced code, pipe tables, lists one level deep, blockquotes, rules, `code`, bold, italic, links. Anything else is escaped and passed through as text. A construct nobody uses is not supported on purpose: it would be untested code pretending to be a feature. The layout is a sidebar of sections from docs/nav, the page, and its own headings down the right; prev/next chains the nav order; a link to another page is rewritten to its rendered page, so the same Markdown reads correctly on a forge and on the site. The colours are the session's own default theme, so the docs look like the thing they document, and the light variant follows the reader's system preference rather than a toggle nobody would find. Zero JavaScript -- the narrow-screen menu is a checkbox. Two things came out of looking at it in a browser rather than trusting the HTML. The hero diagram was hand-drawn and its lines were 78 and 79 columns long, which is visible. It is generated from a real session now -- three panes, a layout file, a text snapshot -- so it is exactly what the program draws. Terminal art also needs tighter leading and a font whose box drawing joins, so a fence with box characters in it and no language is emitted as art rather than code and styled for that. Detected rather than declared, because the alternative is a marker on every fence and one of them getting forgotten. (Vertical rules still break between lines in a browser: the glyph does not fill its em box, and no amount of CSS changes that. Widths line up, which is the part that matters.) The narrow-screen menu opened over a page that showed through underneath it: as a grid child taken out of flow it kept its content height, so `bottom: 0` did nothing. It has an explicit height now. tests/test_docs.py checks both jobs. The site: every page in the nav is rendered, carries the whole sidebar, is marked as current on its own page, chains prev/next, and no internal link in the output points at a file that is not there. The subset: each construct, and the one that matters most -- markup inside a code span is not markup, because half of these docs are shader expressions full of asterisks. A page missing from docs/nav fails the build, and so does a nav entry with no file: an unreachable page whose only symptom is its absence is the failure mode a hand-written nav has.
* sl0ppty: decode the PNGs programs actually senduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Kitty graphics were green in the suite and a real program still drew nothing. libghostty-vt ships no image codec: built as a library it sets its `sys.decode_png` hook to null, and a null hook rejects every `f=100` transmission outright -- no image stored, so no placement exists, so the re-emission layer this project is mostly *about* is never reached. The program gets no error and no picture. Raw RGB worked throughout, which is why it looked done: every test here sent raw pixels, because that is what is easy to write in a test. It is the same lesson the last kitty graphics bug taught -- write the test the way a program actually behaves -- and a program with a picture on disk has a PNG. Found because ida-tui's startup logo never appeared, and its detection probe answers `OK` first, so it fails silently rather than falling back to its block art. PNG is the whole of the gap and cannot grow: the protocol has exactly three formats (`f=24`, `f=32`, `f=100`), the first two are raw pixels lib-vt already handles, and `decode_png` is the only codec hook it has. Decoding only, never encoding. The decoder is the stb_image lib-vt already vendors and we already commit -- upstream compiles that same header with the same `STBI_ONLY_PNG` for this same purpose, it is just not in the `-Demit-lib-vt` build. So nothing new is pulled in; a file we already ship is finally compiled. src/png.c is the hook, a header pre-check and a copy: dimensions are read before decoding because the buffer we return is allocated through lib-vt's limited allocator but stb's own working allocation is plain malloc and answers to nothing. Tests cover a PNG placed at the size it asked for (RGB and RGBA sources, both normalised to RGBA), the client being handed `f=32` rather than the png it was given, and a malformed png placing nothing without taking the pane with it. They fail on the previous binary.
* make coverage: a number, cheaplyuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `make coverage` builds a second binary with gcc's --coverage into build/cov, runs every suite against it, and prints a per-file table from gcov. About a minute. Nothing installed, nothing about the normal zig build changed. gcc rather than clang because the counters need a gcov that matches the compiler, and gcov is what this machine has -- no llvm-profdata, no llvm-cov, no lcov, no gcovr. The aggregation is forty lines of python reading gcov's own summaries, which is the whole reason this was cheap. The suites drive it through $SL0PPTY_BIN, one line in the harness, so there is no second copy of the test code and no risk of the coverage run testing something else. Two things about the result, both in the report itself: - the C unit tests link the *same* instrumented objects, so input.c, kdl.c, shader.c and expr.c are credited for the tests that exercise them. Built separately, input.c read 54% while having a dedicated decoder test -- a lie about where the tests are rather than about the code. - server.c reads ~8% and is not untested. The session server is a daemonised child ending in _exit(), which is right there and skips the flush gcov needs. The counters are lost, not the tests. Said in the output, because an unexplained low number invites someone to "fix" working code -- and changing correct code to make a tool happier is the wrong trade. Where it lands today: 85.4% of 6298 lines. The gaps that are real rather than mechanical are jsonval.c (58%), screen.c (64%) and pty.c (59%) -- error paths and the parts of the diff emitter that only a resize or a weird terminal reaches.
* sl0ppty: 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.
* contrib: a shadertoy for shader expressionsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | You cannot see an expression by reading it. `(y % 2) * 40` is scanlines and `(x > cols - 10) * 120` is a right margin, and neither is obvious until it is on a screen -- so here is a screen. One HTML file, no build and no server: open it and it works, offline, including from a checkout on a machine that has never run sl0ppty. It draws a fake pane with plausible contents, applies a chain of passes to every cell, and prints the config line to paste. The mouse moves the cursor, so `dist(x, y, curx, cury)` behaves the way it will in a pane rather than the way it does at coordinates you imagined. Anything reading `t` animates. The presets are every built-in written as an expression, which is the equivalence the whole design rests on: `vignette` and `spotlight` are not special, they are `dim` with an argument. It also reports what each expression *reads* and whether that makes it cacheable, because the dependency analysis is the interesting half of the implementation and is otherwise invisible. The hazard is that this is a second implementation of the language, in another language, and it would disagree with the compiler quietly -- in the third digit of a colour, on one preset, at one cursor position. So tests/test_shadertoy.py lifts the evaluator straight out of the page (not a copy of it, the page itself) and runs a few hundred expression/environment pairs through both it and src/expr.c, including the corners: division by zero, the ternary's untaken branch, `dist` counting a row double, and six expressions that both sides must refuse. build/expr_eval is the real compiler as a filter, which is what makes that comparison possible at all. It found a bug on its first run, in the page: unary `-` and `!` did not thread the environment through their closures, so `!x` threw instead of evaluating. Which is the argument for the test, made better than I could have made it. Skipped with a message when node is missing -- a checker for a contrib toy is not a reason the suite cannot run.
* sl0ppty: 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: 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: saving the config reloads ituser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The reload existed but you had to ask for it, which is the wrong shape for the thing it is mostly used for: changing a colour and looking at the result. The server now watches the config and re-reads it when it changes. It watches the config's *directory* and filters by name, rather than watching the file. Editors overwhelmingly save by writing a temporary file and renaming it over the target, which swaps the inode out from under a watch on the file itself — it fires once and then never again, which is worse than not watching at all because it looks like it works. The test writes in place *and* renames over, because only the second one would have caught that. Reloading goes through the same call the command does, so it keeps the fail-open behaviour: a file that does not parse leaves the running config alone and says so in a toast. That matters more here than it did for the command, because a file caught halfway through a save is a normal thing for a watcher to see rather than an operator mistake — and there is a test for it, and for the recovery once the file is valid again. Turning it off is a command-line flag rather than a setting, because a setting would be circular: you would have to reload the config to stop it reloading the config. --no-reload only stops the *watching*; asking explicitly still works, which is the distinction the flag name is trying to make. The test is a live one because inotify lives in the server's poll loop and the headless driver has no server. It polls for the colour it expects rather than sleeping a guess, so the passing cases cost milliseconds; the two cases that assert nothing happened are the only ones that wait, because proving a negative has to.
* sl0ppty: the test cycle was 80s, it is now 5.5suser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Measured before changing anything, because the obvious suspect was wrong: the build is not slow. zig caches objects, so even a clean rebuild is ~0.15s and an incremental one is 0.08s. The C unit tests are 0.00s. All eighty seconds were in the python suite, and almost all of that was waiting on durations that were guesses. Four things, in order of what they bought. Run the python tests in parallel. They are separate processes that share nothing — script mode opens no socket — so each is now its own make target with a stamp, and `make -j` runs them at once. 78s -> 21s for free. The stamps are worth as much as the parallelism day to day: an untouched test whose binary has not changed does not run again, so `make test` after editing one test file is 1.1s and after editing nothing is 0.02s. The binary is a prerequisite of every stamp, so changing the code still re-runs everything. Then stop guessing at durations. `settle N` is the only thing that reads a pane's pty, so it is how you wait for a program's output — but `send` and `snapshot` are already synchronous with the app, which means a settle after a mouse event, waiting to assert on our own chrome, is waiting for nothing at all. Those are removed. Where a program's output really is the thing being waited for, harness.until()/until_text() polls in 10ms steps and returns the instant it lands: measured, that is one poll, against the 200-300ms those calls used to donate. The default settle drops 120ms -> 20ms on the same reasoning. Three effects are gated on a real clock rather than on anything observable — the hover dwell, and a toast's lifetime. Those genuinely have to wait, so the tests that are not *about* the timing now configure the clock down instead (hover_delay_ms 20, toast_ms 150) and the two tests that are about it keep the real defaults, because a dwell test that cannot observe not-yet-elapsed proves nothing. Two tests turned out to be passing by accident, which is the useful kind of thing a speedup shakes loose: both asserted the resize hint was gone after a drop while the pointer was still sitting on the handle. It was only gone because the dwell had not re-elapsed — resting on a handle *should* re-arm the hint. They now move off the handle first and assert the thing they meant. Unrelated but found on the way: the Makefile tracked no header dependencies, so editing a .h rebuilt nothing and the only safe move was `make clean`. -MMD -MP fixes it; touching shader.h now rebuilds exactly the six objects that include it. That one was a correctness bug wearing a performance costume. .fastfeedback/SPEED.md records the measurements and how to write a fast test here, so the next person does not rediscover that settle-after-a-click is free to delete.
* sl0ppty: drag greys the other panes, and dim_unfocuseduser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The first two consumers of the shader pass, and between them they settle how a pane gets one. Dragging a pane by its title now greys every *other* pane, so the one that is moving lifts off the page — which is the thing that made a colour pass worth building. It engages only once the pointer has actually moved, because a press on a title that turns out to be a click would otherwise flash the whole session grey on its way to doing nothing. dim_unfocused fades everything but the focused pane and is off by default: an always-on effect is the kind you stop seeing and start fighting, so it should be asked for. Both are derived every frame rather than attached and cleared. Focus and drags move through far too many paths — hover, click, the finder, a close, a layout apply, any keystroke ending a drag — to keep an attachment in sync with, and a pane left grey by the one path that forgot is a bug that only turns up in front of someone. The rect and the collapsed flag are recomputed every pass for exactly this reason; this is the same rule applied to colour. It also makes the awkward cases free: dropping into the margin, a keystroke ending the drag, or the dragged pane being closed under it all un-grey without anywhere having to say so, and there are tests for each because "it turns off again" is the whole risk. The two policies replace rather than stack. Dragging an unfocused pane while dim_unfocused is on would otherwise give it two reasons to be grey, and they compound into one muddy grey that reads as neither; the drag wins, so the dragged pane is at full colour whatever the focus says. shade_apply no longer caps at SHADE_MAX. That cap was right when the only shaders were the ones a pane had attached, and wrong the moment the session derives one on top of them: a full stack would have silently dropped it. The count is the caller's to bound. The chrome check that mattered was already there: test_drop_target_is_visible samples a frame corner mid-drag, and it still passes, because the pass runs between the contents and the chrome drawn over them.
* sl0ppty: terminal shaders — a colour pass over pane contentsuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A shader is a pure function from (cell, position, parameters) to that cell's colours, applied to a pane's contents. Tinting, dimming and greying are the first three; the point is the mechanism, because it is going to carry a lot of UX later — drag a pane and grey the others, dim what is not focused, mark a pane that wants attention — and all of those want to be a colour pass rather than another special case in the drawing code. Where it runs is the whole design. draw_cb already paints the frame, then the contents, then the split guide, so the pass goes between the last two and "contents, not chrome" stops being a rule anyone has to remember: the frame was painted before and lies outside the content rect, and an affordance painted after stays legible on top of a pane that has been dimmed underneath it. node_t already carries the content rect, so the pass has its bounds. Cells only, and colours and attributes only. Rewriting a cell's text would desync selection and copy, which read the terminal rather than the screen, and could break the wide-cell invariant; the tail half of a wide cell is skipped for the same reason, since shading it computes a colour nothing displays. Each cell is shaded independently — no neighbours — so anything positional works and convolutions do not. That is deliberate: blur would need a second buffer, and everything actually wanted here is a function of position and colour. The context carries the content size and rect-relative coordinates so gradients and vignettes are expressible, plus a timestamp for whatever ends up animated. The one real problem is that "terminal default" has no RGB. We never learn what the client's default foreground is, and most terminal text is default-coloured, so a shader that skipped those cells would leave nearly everything at full brightness and read as broken. The pass materialises them to a configured default_fg/default_bg (white on black) before running, which means a shaded pane stops tracking the terminal's own theme for those cells — so the config exists to make that right, and asking the terminal directly with OSC 10/11 is the eventual fix. A pane with no shaders skips the pass entirely and still defers to the terminal exactly as before. Attachment is internal policy: app_shade_add/clear, callable from inside the app and from nowhere else. Not over the control API and not in-band, so a program in a pane cannot restyle itself. Nothing attaches one yet. Kitty graphics are re-emitted as images rather than cells, so a shader cannot touch them: a greyed pane keeps a full-colour image in it. Known and accepted. Tested as a C unit test, since the pass is pure: the built-ins and their strength scale, luma weighting, chaining order mattering, defaults being materialised, wide-cell tails and text surviving, degenerate input, what the context tells a shader, and — the one that protects the chrome — that the pass touches nothing outside its rect.
* sl0ppty: the pane title is a name — double-click it to renameuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The top row was both the frame's drag handle and its top edge: resting on it armed the split guide, and a click that never moved split upward. That is right for a border and wrong for the cells the title sits on, because it left no gesture free for naming a pane — a double-click there split twice. So the title text carves its own hit region out of the top row. It is registered after the row's handle and after the title is painted, which is what makes it correct: hit_test scans backwards, so the region that wins is the one whose glyphs the user can actually see, and its width comes from screen_text's return so a title of wide characters claims the cells it drew. Resting there arms nothing (draw_split_guide matches "title:" exactly), and a press takes no side, so the click-splits path — now guarded on a side being set — does nothing. Dragging still moves the pane: that reads as grabbing it by its name. Everything else on the top row still splits upward. Renaming happens in the title cell itself. There is no dialog to place, nothing to dismiss, and the pane it belongs to cannot be in doubt. It is the finder's shape: state on app_t, a key handler that owns the keyboard while it is open, drawn by the pass that draws the frame. Enter keeps the name, Escape abandons it, clicking away keeps it the way leaving a field does. A typed name shadows the program's title rather than overwriting it. The program's OSC 2 keeps arriving underneath, so a shell that rewrites its title cannot undo a rename, and clearing the name gives the pane back to what the program calls itself *now* rather than to whatever it happened to say when the rename began. That is the whole reason pane_t grew a second string instead of a flag. Backspace deletes a character rather than a byte — a title is whatever a program could set, and half a UTF-8 sequence is not a name — and a name too long for the frame scrolls its head off, because the caret is the one thing that has to stay visible. double_click_ms (400) joins hover_delay_ms in the config. The tests keep a control beside each behaviour, since most of these could pass by breaking something else: two slow clicks must open no editor, the top edge must still split, keystrokes must not reach the shell, and a pane closing mid-rename must give the keyboard back.
* sl0ppty: one-shot --headless printed a blank screenuser2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | `--headless` is documented as "run once, print the screen", and it printed nothing for any command that finished — which is most of them. `make smoke` had been showing six blank rows. settle() calls app_reap() every iteration, and app_reap closes a pane whose child has exited; closing the last one takes the tab with it. A command like `sh -c printf ...` exits immediately, so the pane and its content were gone before app_compose ever ran, and the compose found an app with no tabs. So settle() now takes an optional screen and composes into it before the reap rather than after. This is exact rather than a narrowed race: pane_pump() only marks a pane dead on read() returning 0, and it reaches EOF only after it has drained everything the pty had, so a pane cannot be marked dead while its output is still pending and the last snapshot always holds the finished screen. --script passes NULL and is byte-for-byte unchanged. smoke also asked for 6 rows, which after the outer margin, the tab strip and two borders leaves about one row for the pane — under min_pane rows, so it collapsed to an empty frame and would have shown nothing even with the fix. It asks for 12 now. Found while renaming; it predates the rename, reproduced on the commit before.
* sl0ppty: rename from sl0pttyuser2026-08-14
| | | | | | | | | | | | | | | | A pty is a pseudo-terminal, and that is what this multiplexes: the extra t was never standing for anything. Renaming now, while the only users are the people who typed the old name on purpose, is cheaper than renaming later. Mechanical, but not only cosmetic. The name is also the runtime contract, so this moves $SL0PTTY and $SL0PTTY_CONFIG, ~/.config/sl0ptty/config.kdl, the socket directory ($XDG_RUNTIME_DIR/sl0ptty, /tmp/sl0ptty-<uid>), and the identity string a program gets back from an OSC 5577 hello. A session left running under the old binary keeps its old socket directory and is invisible to the new one; there is no migration and it does not seem worth writing one. The sl0ppi fork keeps its name: it is a different thing, and D1 still points at it.
* sl0ptty: kitty graphics passthroughsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A program in a pane transmits an image and it appears. libghostty-vt parses the protocol and tracks images and placements; what a multiplexer has to add is re-emitting them to the client, which is exactly what tmux and zellij do not do — and the reason tools fall back to sixel under them. Per frame: walk the visible tab's panes, transmit each image once (a=t), place every visible placement (a=p,C=1) after the cell diff so a repainted cell cannot land on top of one, and delete placements that vanished. Image ids are remapped into a high range, so two panes both using image 7 stay two images and neither collides with what the client's terminal already had. A reattaching client is told everything again, because it has seen nothing. The cropping rule is the part worth remembering: a placement is N pixels drawn across M cells, so the placement itself tells you the pixels per cell — the client's font metrics never come into it. Clipping at a pane edge, or at the top of the viewport when a tall image is partly scrolled off, moves the source rectangle; asking for fewer columns or rows alone scales the image into them instead of cropping it. The first version did exactly that, and dropped partially-visible placements entirely rather than cropping them. Tests cover the multiplexer-specific case first: two panes, both using image id 7, must get different client ids. Then placement position in screen cells, following the layout, cropping at edges and when taller than the pane, nothing from collapsed panes, and removal when a pane closes or the image scrolls away. A live check confirms the wire: transmitted once, re-placed per frame, not re-transmitted. 416 checks green (389 headless + 27 live).
* sl0ptty: the border is the button — click an edge to split toward itsl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The per-pane '+' is gone. It was one glyph for a verb with four directions, so it always split into columns; it taxed every frame three columns of title forever; and there were N of them on screen for one verb. Clicking a pane's border now splits toward that border: the side you click is the side the new pane appears on. Hovering arms the edge (it goes heavy) and draws a dashed line exactly where the new boundary would land, so the gesture explains itself and an idle frame stays quiet. ╭──────────────────── shell ─────────────────────╮ ┃ ╎ │ ┃ ╎ │ hovering the left ╰────────────────────────────────────────────────╯ border │╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌│ hovering the bottom ╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯ border The top border keeps its other job: a click splits upward, a drag moves the pane. A press that never moves is a click, which is a distinction the drag machine already had to make. The guide has to be painted after the pane's content, not with the frame: the dashed line crosses the terminal's own cells, and the first version was drawn underneath them and was invisible. Two old tests changed rather than the code, both because the gesture set is better now: 'beside the split button' is a drag handle, and 'dropping a pane on itself does nothing' is a click, which splits. The replacement asserts what actually needs asserting — a drag that wanders and returns changes nothing. 387 checks green (360 headless + 27 live).
* sl0ptty: select-to-copy, middle-click paste, and toastssl0p2026-08-14
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Drag over text, release, and it is on your clipboard — no chord, no menu, the selection is the copy. The clipboard lives on the client's machine, so the copy travels as OSC 52 and therefore works over ssh; the session keeps its own copy as the primary selection, which is what middle click pastes back. Selected cells are inverted rather than recoloured, so a selection reads the same over whatever theme the program inside is using. A program that is tracking the mouse keeps its own clicks (shift is the escape hatch), which is tested rather than assumed. Toasts: transient announcements in the bottom-right that expire on their own, stacked up to three. 'copied 13 chars', 'config reloaded', a pane's OSC 9 notification, or {"cmd":"notify","text":"..."} from an orchestrator — an agent in a pane can now say 'build finished' and have it appear. OSC 52 from a pane reaches the same clipboard. Two real bugs found on the way: - mouse events are hit-tested against what was painted, and nothing had been painted before the first frame, so a click that arrived first was dropped. Both front ends now compose once before accepting input, which is what really happens in a session anyway. - the plain leaf_new never installed the clipboard and notification handlers; only panes built from a layout file had them. Every ordinary pane was silently missing both. The test escaping traps continue to earn their comments: a real newline inside a driver argument terminates the *command* rather than reaching the shell, \a is not a driver escape (\x07 is), and an interactive shell's line editor eats a typed ESC — so a pane driven with escape sequences must not be one. 356 checks green (329 headless + 27 live).
* sl0ptty: scrollback — wheel, keyboard, and a clickable indicatorsl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | libghostty-vt already keeps the scrollback, so the work was deciding whose wheel it is: - a program tracking the mouse gets the wheel events themselves; - a full-screen program without mouse tracking (vim, less) has no scrollback of ours to show, so the wheel becomes arrow keys, which is what it means to that program; - anything else scrolls our viewport. All three are tested against real programs rather than asserted in principle. C-a PageUp/PageDown/Home/End, and scroll_lines in the config for the wheel. Typing snaps back to the live view, because reading scrollback and then typing into a screen you are not looking at is a trap. New output does not yank the viewport back — it just grows the gap, which the indicator shows. The indicator is compact, right-aligned, budgeted after the split button and before the title (a title is the thing you can most afford to lose), and clicking it returns to the bottom. Three test failures on the way, all mine and all instructive: the top of the scrollback is the shell's first prompt rather than the first line of output; typing without a newline types rather than runs; and two side-by-side panes share a screen row, so asserting on line(y) reads both frames at once. 341 checks green.
* sl0ptty: focus follows the mouse, with the guards that make it liveablesl0p2026-08-13
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Hovering a pane focuses it. The feature is three lines; the reason it is not obnoxious is the guards, and each one is a test: - not while the prefix is held, so a chord cannot be hijacked by a nudge of the mouse; - not through the finder, whose rows are on top of the hit list anyway; - not during a drag, which owns the mouse until the button comes up; - not onto a collapsed pane. Only 'pane:' and 'title:' targets count, never the frame rect's 'focus:', so the pointer crossing a stack of headers does not expand three panes on its way past. Off with 'focus_follows_mouse false'; clicking still focuses either way. This needed the client to ask for ?1003 (any motion) rather than ?1002 (motion while a button is held), which fixes something that was quietly broken: a pane requesting any-event tracking could never receive hover, because the outer terminal was never asked to report it. It can now, and a pane that asked only for clicks still gets none — both tested. One real bug behind that: our decoder represents 'no button held' as MBTN_UNKNOWN, and pane_send_mouse was passing it to the encoder as a concrete button. lib-vt has ghostty_mouse_event_clear_button() for exactly this, and without it bare motion encoded to nothing at all. 320 checks green.