| Commit message (Collapse) | Author | Age |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An ed25519 key made for exactly this -- slosh package signing
<slosh@slosh.foo>, 0F67C909938CFFA8A76B59FD8D69B55A0E9056B9 -- signs every
package and both indexes. The public half travels two ways that would both
have to lie: contrib/arch/slosh.asc in git, and /arch/slosh.asc on the
website; the READMEs walk through pacman-key --add and --lsign-key, and the
[slosh] stanza loses its SigLevel override because pacman's default demands
signatures and now gets them.
contrib/arch-repo pins the fingerprint, refuses to run without the secret
key (an unsigned update would make every enrolled pacman refuse the repo),
and now regenerates the index from the directory every time: repo-add skips
an entry whose version it already has, so after a same-version rebuild an
incremental add left the db describing the previous bytes -- a checksum
mismatch for every client. An index rebuilt from disk cannot disagree with
disk. Verified end to end with a scratch pacman keyring: enrolled key
syncs and downloads under Required DatabaseRequired, unenrolled key is
refused at the database.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
AUR signups are closed while the AUR deals with supply chain attacks, so
the package is published from our own roof -- which is the better offer
anyway: a pacman repository serves built packages where the AUR hands
every user a rebuild. contrib/aur/ becomes contrib/arch/ (the .SRCINFO
goes; it is AUR metadata, regenerated in a minute when signups reopen),
and contrib/arch-repo builds the package and maintains the slosh.db /
slosh.files indexes in dist/arch/<carch>/, which rsyncs to
/opt/slosh/arch/ on the box. The README and the front page carry the
[slosh] stanza; SigLevel is spelled out because the packages are not
signed yet and pacman defaults to demanding it.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
contrib/aur/ is the upstream copy of the AUR package; the AUR repo gets
these files verbatim on a release, and contrib/release now says so in its
checklist. The source is the cgit snapshot of the tag, which carries the
vendored libghostty-vt -- and every zig dependency in its build.zig.zon is
lazy and unused by the VT-only build (verified against a cold
ZIG_GLOBAL_CACHE_DIR), so the build fetches nothing and is clean in an
offline chroot. prepare() writes pkgver into VERSION because the v0.1.1
snapshot predates the release script that keeps the floor bumped; for
later tags the line writes what is already there.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
web/ and tests/fuzz/ are excluded from the sync (hundreds of megabytes,
none of it an input to 'make all') but both are tracked, and .git goes
over whole -- so the moment a commit touched web/, the builder's stale
copy read as a local edit and git describe stamped a clean release tree
-dirty. slosh-0.1.1-dirty-macos-arm64.zip was signed, notarized and
correctly labelled as a tree nobody had: the exclusion's debris, not
the release.
After the rsync, restore the excluded tracked paths from the .git that
just went over: no network, no bandwidth lost to the exclusion, and a
tree that is really dirty still says so.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The post-notarization Gatekeeper check ran spctl exactly once, immediately.
A bare CLI binary cannot be stapled (stapler only takes .pkg/.dmg/.app), so
there is nothing on disk to carry the ticket and Gatekeeper must fetch it
online from Apple's CloudKit -- which lags the notary's own 'Accepted' by
seconds to a couple of minutes. The single check raced that propagation and
failed the whole release with 'Unnotarized Developer ID' for a build Apple
had already accepted (status: Accepted), taking the good linux tarballs and
the SHA256SUMS down with it.
Poll spctl instead: pass the instant the ticket appears, and if it still has
not after ~2 min, report it as pending and let the release stand -- the notary
verdict is authoritative and codesign already vouched for the signature. Any
other rejection still fails hard, so a real signing problem is still caught.
The accepted vs pending states are told apart by 'Notarized' vs 'Unnotarized'
so there is no false positive.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The working-tree sync excluded two of the three gitignored
vendor/libghostty-vt zig caches but not zig-pkg/ (354M), so every
run silently re-pushed it. With rsync -a and no progress output that
looked like a hang on the rsync step.
Add the missing zig-pkg/ exclude (keeping the list in step with
.gitignore), plus web/ (~430M landing page/demo) and tests/fuzz/
(~120M corpus), neither of which is an input to `make all` -- all
the builder runs. Cuts the sync from ~1.2G/48k files to 350M/9.6k.
Only affects the default working-tree mode; REF=<tag> builds check
out on the builder via git and were unaffected.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Nothing about updating the formula needs a mac or needs brew -- it
resolves a ref, fetches a tarball, hashes it, rewrites three stanzas.
But it hashed with `shasum`, which is perl's and only usually present
on Linux, where `sha256sum` is coreutils and always is. Take whichever
exists, as macos-remote-build already did.
The version count no longer falls back to 0 when the commit is not in
this clone. `0.1.0-0-<short>` is older than every version published, so
a release cut from a clone that had not fetched yet would have looked
like a downgrade and never installed; say so and stop instead.
Also written down: why the formula builds from source rather than
shipping the notarized zip the mac builder makes -- brew's curl sets no
quarantine attribute, so notarization gains a formula nothing, and the
build is a minute with zig as its only dependency. If that ever changes
the answer is bottles, which are additive and cost a per-macOS-version
matrix, not a cask.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`brew install slosh` is the shortest install there is, and the formula is
small because `make install` already does the work: zig as the only build
dependency, both zig caches redirected into the build directory so the
sandbox does not deny them and the user's real cache is not polluted, and
the commit id handed to the Makefile because a snapshot tarball has no
.git for `git describe` to read.
The url/version/sha256 triple is the part that rots. contrib/brew-release
resolves a ref to a full commit id, fetches the cgit snapshot, hashes what
it actually got, and rewrites those three stanzas -- it does not commit or
push, so the diff is read by a human first. Untagged pins carry the commit
count so `brew upgrade` can tell newer from older; alphabetical comparison
of two short ids cannot.
The formula lives here as the source of truth and is copied into the tap;
contrib/homebrew/README.md says why it is a formula and not a cask, and
what still stands between this and homebrew-core.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Portability fixes contributed by our macOS developer, applied with one
hardening tweak (below).
Darwin has no /proc, so everything that read one gets the native answer:
live_cwd via proc_pidinfo(PROC_PIDVNODEPATHINFO), the foreground command
via sysctl(KERN_PROCARGS2) parsed back into the NUL-separated shape
/proc/<pid>/cmdline gives, and SLOSH_BIN via _NSGetExecutablePath plus
realpath. The pty gets its winsize set on the slave after opening it,
because Darwin attaches no line discipline to the master until then and
the earlier TIOCSWINSZ was silently dropped -- a pane born 0x0. The
shader repl binds tab in libedit's own words, since macOS ships Python's
readline as a libedit wrapper that accepts GNU syntax in silence.
Two finds are cross-platform bugs: a toast wider than the screen minus
the gap computed a negative x that uint16_t made ~65533, drawing the
longest messages -- the ones most worth reading -- off screen; now the
middle is elided (a path's tail is the half you needed) and x clamps.
And a pane label was 128 bytes, which a Homebrew interpreter's full path
exceeds before its arguments even start; dump-layout wrote the
truncation down and a restore ran it, so it is 1024 now.
Saving a layout learned cwd_as_named: the kernel answers with the
physical path, and on macOS /tmp is a symlink to /private/tmp, so a
layout that said cwd="/tmp" stopped round-tripping through a save.
If realpath(named) is where the pane still is, the named spelling wins.
Tests follow: realpath'd tmpdirs, the scroll test reads the shell's
first prompt instead of spelling out bash 5.3, and wrapped pane text is
joined before matching a long path.
One tweak on top of the contribution: the elision needs five columns
for a head, the dots and a tail -- below that `avail - 3` goes
negative, and negative as size_t is a pointer into the weeds; a
terminal that narrow gets a plain clip.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The old name argued that the extension should say how to read the
file. The argument lost to a collision: zellij speaks the same KDL
syntax under that same extension with a different schema, and to
anyone arriving from there .kdl said *whose* file it is -- naming the
wrong owner is worse than not naming the parser. So a layout is
x.layout, a project's is slosh.layout, the name says what the file is
and --check says how it was read. (It is still KDL underneath; point
your editor's highlighting at *.layout.)
Nobody's checked-in workspace vanishes on upgrade: the project scan
still finds slosh.layout.kdl, never writes it, and where both
spellings exist the new one wins, because two files disagreeing needs
one answer. The unparseable-file heuristic in --check learned the new
suffix -- it matched '.layout.' with a trailing dot, which the new
name no longer has.
The webdemo overlay moves with it (demo.layout), rebuilt and
boot-checked.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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().
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 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.
It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.
Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.
Verified after, not before, because a formatter that alters behaviour would be
worse than none:
- `make retest`, 1801 checks green -- and this reformats the suite that does the
checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
time, and `ruff check --select I` has nothing left to say.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.agents/skills/driving-sl0ppty/SKILL.md`. The convention is
`.agents/skills/<name>/SKILL.md` with YAML frontmatter -- already in this tree
under `vendor/libghostty-vt`, so it is not an invention -- and it means an agent
working in a checkout picks the skill up without being told, while a directory per
skill leaves room for adjuncts later. `contrib/README.md` and
`docs/scripting.md` say where it is and how to use it elsewhere: copy or symlink
the directory into wherever your agent keeps skills.
**Writing it turned up the reason there was no skill worth writing.** A pane is
told `SL0PPTY=1` and nothing else, so a program inside one knows it is in a
session and cannot find out *which* -- `ls` lists them all and none of them is
labelled "the one you are in". So the whole most valuable case, an agent driving
the session it is running in, was guesswork. The server now sets
`SL0PPTY_SESSION` and `SL0PPTY_BIN` in its own environment, which every pane
inherits through fork: one place, no signatures threaded, and unset under
`--script`, which has no socket to name. `SL0PPTY_BIN` is read from
`/proc/self/exe` rather than `argv[0]`, and exists because the failure it prevents
is the one I hit writing the examples -- a session started from a build tree, and
`sl0ppty` not on the pane's `PATH`, so the skill's own commands did not run.
What the skill actually teaches, none of which `docs/scripting.md` says because a
reference is not allowed to have opinions:
- Work belongs in a pane that was **given** a command, not typed into somebody's
shell. Then `alive` and `exit_code` off `panes` are how you wait, and the output
survives the program (D14) so it is still there to read.
- There is **no wait primitive over the socket**: `settle` is headless-only, and
`deadline` is about repaints and says nothing about program output. Poll.
- `purpose` is the handle. Titles change underneath you; a declared purpose cannot
be relabelled by the program in the pane, which is what makes it safe to address.
- `snapshot` is the whole composited session, and only what is on screen. Cut a
pane out with its `content_*` rect; do not expect scrollback.
- **The echoed command line matches your own marker.** `echo DONE` puts `DONE` on
screen twice. Count, or use a marker you did not send.
- Write `\e`, never `\033`: the unescaper takes `\0` first, so `\033` arrives as
NUL followed by `33` and nothing complains. That bug was live in this repo's own
test suite until last week.
- A program in a pane can draw its own status and real buttons with OSC 5577 and
read the click off its stdin, which is how it asks a question instead of printing
a prompt and hoping.
- Close what you made; do not `quit` a session you did not start.
`tests/test_skill.py` is the part that keeps it true. A skill is documentation an
agent acts on without a human reading it first, so a stale one is worse than a
missing one: a wrong verb is a failed tool call somebody else has to debug. Every
verb the file names is sent to a live session and must not answer `unknown cmd`;
every `panes` field its central recipe polls must exist; the environment it
promises is read out of a real pane; the recipe itself is run end to end and must
report exit status 3 with its output still on screen. It also greps the file for
paths out of a home directory, because "plug and play" is a property somebody has
to keep.
That test found `close-pane` in the draft, which is the *keybinding* action name --
the verb is `close`. Exactly the class of error the test exists for, found before
anybody's agent ran it.
Verified: 1795 checks, all green, 43 of them in `tests/test_skill.py`. Then the
skill's recipe run verbatim against a real session -- tagged pane, polled to
`alive:false`, `exit status: 3`, `compiling`/`built`/`exited: status 3` still
readable -- and from inside a pane, `session=agentdemo bin=/…/sl0ppty` and a
`building 3/7` status drawn in its own frame.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
sine-comet an arc gliding round, with a sine's shoulders
marching-ants two on, two off, stepping -- the selection marquee
standing-wave three lobes that do not travel; the pattern breathes
bit-drift position XOR clock, three bits: a machine thinking
heartbeat lub-dub, then a rest. For a state
xor-barcode the top rule as a barcode, sliding a character at a time
Each is a thing the old language could not say. `sin` and `TAU` give
`sin(TAU * p / perim)` -- this cell's position as an angle round one lap -- which
wraps at the corner for free, because so does a sine, and which fits any whole
number of lobes to the ring exactly. That last property is what makes a standing
wave stand: three lobes and three nodes, in the same places, breathing.
`>> 1 & 1` is a two-cell dash pattern in two operators, with no multiply and no
table; adding a slice of the clock marches it. `^` gives a motif that reshuffles
rather than slides, which is what a machine looks like when it is thinking and
not what any ramp looks like. And `abs(sin())` over a full turn is two humps, so
a heartbeat is one expression and a window mask.
Two techniques worth stealing from these, and now in the README: **overdrive then
clip** (`* 14 - 3000` after a sine keeps only the top of the crest, turning a glow
over everything into an arc), and the **angle idiom** itself.
Every one of them was measured before it was written down -- the perimeter
unrolled into an intensity strip, frame by frame, so an effect that "should"
travel is seen travelling. Two candidates did not survive that: a Sierpinski
`(x & y) == 0`, which on a ring lights the top and left solid and reads as a bug,
and a two-sine interference, which read as noise. `tests/test_chrome_presets.py`
picks up all six with no change, and asserts of each what it asserts of the other
seven: the frame moves, and the pane's own output does not.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Angles here are degrees, and pi in an angle language is not a number -- it is a
half turn. So `PI` is 180, `TAU` is 360, and a formula written the radian way
ports across as written:
sin(TAU * x / cols) one cycle over the pane's width
sin(TAU * p * 3 / perim) three lobes fitted exactly to a border
PI / 2 a quarter turn
PI / 6 thirty degrees, exactly
That last line is the argument. A numeric pi would be 3 in this language, and
`3.14159 / 6` in integers is 0 -- so radians cannot express a sixth of a turn
here at all, while degrees give it as a whole number. The pun is not a
compromise; it is the more precise reading of the two.
Which is why there is no `deg2rad` or `rad2deg` beside them, and asking for one
says `unknown function`. There is one angle unit. A conversion could only lose
the angle (`deg2rad(90)` is 1, and `sin(1)` is nearly nothing) or invent a second
scale for the language to disagree with itself about.
Spelled-out numbers rather than variables: they read no environment, carry no
dependency, and fold into the program like the literals they are. The browser
preview learned the same two, from the same table.
Verified: 12 cases in `tests/expr_test.c`, including that `deg2rad` and
`rad2deg` are refused; the cross-check agrees with the compiler on 700
evaluations, now including `sin(TAU * x / cols)` and friends.
Two smaller things ride along, both from the repl. `PI`/`TAU` tab-complete, and
its drift test grew a third table to check -- it already caught `sin`/`cos`
missing before I did. And it now checks the script *parses* before starting a
pane that runs it: I broke the indentation of one line here, and the failure
arrived as `IndexError: list index out of range` from a pane that died before
its prompt, which is a long way from "line 212".
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
128 + sin(t / 4) / 2 a breathe that is actually a sine
abs(sin(x * 6 + t / 5)) a wave travelling along a frame
abs(sin(dist(x, y, curx, cury) * 20 - t / 4)) / 2 ripples from the cursor
Degrees in, -255..255 out. Two decisions, both for the same reason -- this is a
language people type into a config file, not one they read a manual for.
Degrees because that is what anybody guesses, and 255 because it is the scale
every strength here already uses: `128 + sin(t / 4) / 2` is a breathe centred
half way up with nothing to rescale, and `abs(sin(...))` is an amount as it
stands.
Every angle wraps, negative ones included. `t / 4` does not stop at 360 and an
expression that died at one turn would be no use for the one thing this is for.
A 91-entry table of round(255*sin) at one-degree steps, three quadrants by
symmetry, no interpolation -- the argument is an integer and there is nothing
between two degrees to interpolate. A table and not a polynomial because
`contrib/shadertoy.html` reimplements this language in JavaScript so the preview
can run in a browser, and `tests/test_shadertoy.py` compares the two on every
expression: identical data with identical reduction cannot drift the way two
approximations of one curve would. The JS carries the same 91 numbers.
Until now the only oscillator was `abs(t / 6 % 510 - 255)`, a triangle wave
written as arithmetic. It works and it looks like what it is -- a ramp with a
corner in it. A sine has no corner, which is the whole difference between a pane
that pulses and a pane that flickers.
Verified: 19 cases in `tests/expr_test.c` (the cardinal angles, quadrant
symmetry, wrapping past a turn and below zero, and the breathe idiom); the
cross-check now agrees with the compiler on 660 evaluations including generated
trig; the repl's completion drift test caught `sin`/`cos` missing from its list
before I did, which is what it is for. Then six chains in a real pane -- breathe,
travelling wave, clipped crests, plasma, rolling scanlines, cursor ripples --
each measured over eight distinct frames.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`&` `|` `^` `~` `<<` `>>`, on the 32-bit value the VM already used. There was
nothing: `x & 7` answered `unexpected trailing input at offset 2` and
`(x ^ y) % 16 < 2` answered `expected ) at offset 3`, which is a fair
description of a parser that had never heard of either.
`^` is the reason to bother. `(x ^ y) & 1` is the cheapest interesting texture
there is, and the language could express a sweep, a ramp and a comparison but
not one bit of pattern without `%` gymnastics.
**Bits bind tighter than comparison, which is not what C does.** `x & 7 == 0`
means `x & (7 == 0)` in C -- a precedence Ritchie put on record as a mistake,
kept for a decade of compatibility that a shader expression in a config file
does not have. Here it asks what it looks like it asks. Among themselves the
usual `&` then `^` then `|`; shifts stay where C has them, just under `+ -`.
`&&` and `||` still parse as themselves: the bitwise rungs decline the first
character when a second one follows it.
Semantics are JavaScript's, deliberately and to the letter -- the shift count
masked to five bits (`1 << 33` is 2), `>>` keeping the sign (`-8 >> 1` is -4).
Not taste: `contrib/shadertoy.html` reimplements this language in JS so the
preview can run in a browser, and `tests/test_shadertoy.py` compares the two on
every expression it can think of. Anything C leaves implementation-defined had
to be pinned to what JS would show, so the signed right shift is written out in
the VM rather than left to the compiler.
Verified: 22 new cases in `tests/expr_test.c` covering the operators, both
precedence questions, the sign, and the masked count; the preview learned the
same six operators and the same ladder, and the cross-check now agrees with the
compiler on 564 evaluations, including generated ones that reach for bits. Then
seven bitwise chains in a real pane -- an XOR texture, a drifting one, a stepped
pulse that walks eight shades of `t >> 7 & 7` and needs no `abs`.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Dogfooding this while building it has one sharp edge: a running session keeps
the binary it started with. `reload` re-reads the config, it cannot re-read
the code, so every build you want to try means losing your screen.
The only thing worth carrying across a restart is the shape, and sl0ppty
already knows how to read one -- it just could not write one. `dump-layout`
is the inverse of `--layout`: tabs with their names and purposes, how the
panes are split, in what proportion, in which directory, running what, which
tab was active and which pane had focus.
Two details make it worth trusting. The directory is where the shell *is*,
read from /proc, not where the pane was started -- after any amount of `cd`
those differ and only one of them is useful. And a pane running the session's
default shell is dumped with no `command`, so it comes back as a shell rather
than as a shell wrapped in a shell, one layer deeper every restart.
contrib/sl0ppty-dev is the loop: attach, and when the session ends because you
asked for a restart, come straight back with the layout. It is two halves
because it has to be -- `restart` runs inside the session, and the moment the
session ends so does the shell it was typed in, so it leaves the layout and a
note and the loop outside picks them up.
What does not come back is what was running *inside* a program. A shell
returns as a shell, in the right directory, with an empty history. That is the
limit of restoring a layout rather than a session, and the script says so
rather than implying otherwise.
Three bugs found by the round-trip test, all the same kind -- a dump that
loads without complaint and rebuilds something subtly different:
- `split=` belongs on the node that *has* the children, not on the children.
Written the other way it parses fine and gives the wrong tree.
- a split's weight was never restored, because the edit meant to read it
silently matched nothing and was never applied.
- `active=true` landed on the wrong tab: the index is recorded while the new
tabs sit after the old ones, and `replace` then moves them to the front.
Which is why the test asserts that a dump of a reload is byte-identical to
the first dump. Anything the dumper loses shows up there at once, where
reading it and nodding would not.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
They only existed in a browser, which is a strange place for a terminal
multiplexer's shaders to live. Now each one is a file you can paste.
contrib/shaders/ holds all thirty-two, one per file, *generated* from the
presets in shadertoy.html by contrib/gen-shaders. The page stays the source:
it is where they are written, previewed and conformance-tested against the
compiler, and a hand-kept second copy would drift the day after it was
written. `gen-shaders --check` says whether the files are current, which is a
test, so the drift is a failing build rather than a stale example.
contrib/shader-tour cycles a session through them, in the shape theme-tour
already established — write the config the session watches and let the
watcher do the rest. It takes one shader, one group, or nothing for all of
them, and it resolves a short name (`torch`) as well as the whole slug
(`guides-torch`), because nobody remembers which group a shader is in. The
tour writes the theme alongside the shader: shaders resolve default-coloured
text through theme's default_fg/default_bg, so a preset judged without one is
being judged against a guess.
The new test is the one that matters. Every shipped file is loaded into a
real session and has to *change the cells of a pane* -- an example that
previews beautifully and does nothing when pasted is worse than no example,
and neither the toy's conformance test nor a config parse would notice. Three
of them are then checked for the specific thing they claim: scanlines
alternate row by row, indent guides land on every fourth column, a flat dim
is flat. One session, reloaded per preset, which is both quick and a fair
exercise of the path anyone will actually use.
Renamed the "the built-ins, as expressions" group to drop the qualifier from
its filenames; it was generating built-ins-as-expressions-scanlines.kdl,
which is a sentence rather than a name.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Eleven examples, grouped, and most of them are less "effect" than
*instrument*: a cursor line, a crosshair, indent guides, a right margin at
three quarters, a torch that dims everything more than eight cells from where
you are looking. Things that tell you where you are, inside a program that
has never heard of any of them -- which is the argument for shading contents
rather than chrome, made in examples instead of prose.
Then the fun: phosphor and amber CRTs (grayscale, tint, scanlines, vignette
as a four-pass chain), film grain, a rolling bar, an interlace flicker; sonar
pings expanding from the cursor, ripples, a barber pole, plasma from two
triangle waves, matrix rain with a fading tail; and every built-in written as
an expression, because `vignette` being `dim` with an argument is the whole
design and is better shown than argued.
Two rules the presets follow, both about staying identical to the C: never
multiply `t` (it is a millisecond clock and gets large -- divide it down
first and the arithmetic stays in an int32 forever), and keep hash
coefficients small for the same reason. `(x * 11) % 23` is fine; the usual
Knuth constant is a different number in C than in JavaScript.
The test now lifts the *presets* out of the page as well as the evaluator, so
one added later is conformance-checked without anyone remembering to add it.
And it evaluates every preset over a whole pane at four points in time and
fails any that is zero everywhere -- an example that does nothing reads as a
broken shader system rather than a bad example.
That check caught its own first draft: sampling every other row made three
`y % 2` effects look dead when they were fine. It samples every cell now,
which costs six hundredths of a second for four hundred thousand
evaluations, because the thing being tested is quick.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The canvas was 576x432 with `max-width: 100%`, so a wide pane stretched a
576-pixel preview across it and `image-rendering: pixelated` made sure you
could see every one of those pixels doing it. A preview of a terminal that
does not look like a terminal is not much of a preview.
Two sizes now, deliberately: the backing store is device pixels (DPR, capped
at 3) so glyphs stay sharp on a HiDPI screen, and the CSS size is pinned to
the real 576x432 so nothing ever scales it up. A viewport with room to spare
gets space around the preview instead of a blur; one that is genuinely too
small still shrinks it, smoothly, which is the direction where scaling is
fine.
Checked by rendering a frame against a stub canvas under node: the draw path
covers exactly the CSS box while the backing store is twice that at DPR 2.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
|
|
Six themes, each setting every colour the config knows about. A theme that has
fallen behind silently keeps the compiled-in default for whatever it forgot,
which looks like a bug in the theme rather than a missing line in it, so a test
compares each file against the names config.c actually parses.
contrib/theme-tour applies one to a running session, or cycles the lot. It does
that by writing over the file the session was started with and letting the
config watcher notice, which makes the showcase a demonstration of the watcher
as well.
HANDOVER.md is for the next context. It deliberately does not restate DESIGN.md
or the README; it is the part that was only learnable by getting it wrong —
which state is derived and which is intent and what happens when you confuse
them, the dwell repaint list, that glyph width and glyph weight are different
problems, that settle() in the harness is a sleep in disguise, and that a test
pinned to a coordinate it does not care about will eventually look like a
regression it is not.
|