aboutsummaryrefslogtreecommitdiffstats
path: root/docs (follow)
Commit message (Collapse)AuthorAge
...
* docs: the Windows port, and what is different therePeter Geissler2026-08-28
| | | | | | | | | | | | | | | | An install subsection alongside macOS and Linux, and a page under Inside for the rest: how to build it, what changes about running it (%ComSpec% panes, sessions in %LOCALAPPDATA%), and the reasoning the code can only gesture at in comments. The three traps are written down in full, because each one presents as something other than what it is and cost real time: a pane with a title and no body (the child inherited slosh's stdout rather than the pseudoconsole), a pane that stays alive after `exit` (a ConPTY's output pipe belongs to the console, not the child, so the read never ends), and an editor refusing a path that plainly exists (cmd.exe and CommandLineToArgvW disagree about what \" means, and separately the CRT accepts a mixed-separator path where a GUI dialog will not).
* install docs: one subsection per OSuser2026-08-27
| | | | | | | | | | | | 'With Homebrew (macOS or Linux)' followed by a source build made every reader parse which half was theirs; now macOS and Linux each have a heading, and you read yours. macOS is the tap; Linux is the same tap under Homebrew on Linux, or the source build, which the docs landing page had never mentioned brew at all. Also fixes the README telling people to run bare 'make', which prints the help text (the default goal) and builds nothing. install.sh had this figured out and commented on it; the README had not.
* README/docs: stop leaning on the aside dashuser2026-08-27
| | | | | | | | | | | | | Some 230 sentences hung their qualifications between dashes, and at that density the tic reads as the writing's signature rather than a tool it occasionally picks up. Each one is now the punctuation the sentence actually wanted: a colon where the aside defines, parentheses where it whispers, a comma where it barely pauses, a full stop where two thoughts were pretending to be one. Definition lists and reference tables take colons throughout. Two double hyphens remain on purpose, both literal: the argv separator in a shell example, and a table cell that means 'takes nothing'.
* README/docs: spell the dash --, as the rest of the tree doesuser2026-08-27
| | | | | | | | | | | Every C comment, commit message and generated config in this repo writes the aside dash as a spaced double hyphen; the Markdown alone used U+2014, some 220 of them. One spelling, and the ASCII one -- the prose now matches the code it documents. Mechanical (every occurrence was the spaced form, checked including the one inside a code fence, where it was comment text), so the words themselves are untouched.
* docs: the landing page stops arguinguser2026-08-27
| | | | | | | | | The 'Why another one' section was two paragraphs of case-making against other multiplexers, and a landing page that defends its existence reads as unsure of it. What the section said truthfully -- the vendored core, recompute-not-remember, scriptable to its depth -- design.md already says where it belongs. The one-line closer stays: 15k lines of C and no dependencies is a fact, not a defence.
* docs: drop the ascii mockup from the landing page toouser2026-08-27
| | | | | | Same reasoning as the README's (24a6db01): the sketch says nothing the first paragraph did not, and on the rendered site it sat where the reader was deciding whether to keep reading.
* 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.
* 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.
* docs: config.md audited against the code, three correctionsuser2026-08-26
| | | | | | | | | | | | | | | | | | An audit of every claim in docs/config.md against the parser and the dump found the page complete -- all 47 top-level settings named, no ghosts, defaults and error strings as printed, 48 theme colours exactly -- and three statements wrong or imprecise, all fixed here and in the two comments that repeated one of them: - compact does not switch off gap_aspect. The divider ignores it, but padding is still aspect-corrected through it and the float nudge step still uses it; saying it 'stops applying' was wrong in config.md, config.h and config/config.kdl alike. - 'Two of those take more than one value' miscounted: padding, min_pane/min_split (cols=/rows= properties, whose syntax the page never showed) and project_roots all do. - a compact boundary saves four *columns* or three *rows*; 'four' with no axis was the columns number wearing a general claim.
* compact: shared borders instead of gaps (D23)user2026-08-25
| | | | | | | | | | | | | | | | | | | | | | One knob, `compact true`: panes pack flush against 1-cell divider lines that meet in real junctions, one frame rings the tab, and a pane's title rides the line above it -- a boundary costs one cell where the default look spends four. The lines are strokes: each write says which edges of its cell it needs, unions with what is there, and never clobbers text, so ├ ┬ ┼ fall out of paint order and the focus ring recolours without inventing arms. The divider *is* the gap (eff_gap, one answer for the layout, split_fits and the resize clamp), so gap hits, hover hints and corner drags work unchanged. Interior edges give up click-to-split -- one cell cannot hold two verbs -- and a pane's status row overlays its last content row. Floats, zoom and the D6 flatten keep classic frames through place_hidden: zero new tree cases. Underneath it, mode or no mode: a border drag now moves the boundary on that side, converted on the first motion so the press that never moves keeps its old meaning -- the handle's click still splits, the rim's is still deliberately nothing (drag.rim). Which is also the fix for gap 0, where flush panes leave no gap cell to grab and the resize gesture had silently vanished with it.
* docs: fact-check against the loaderuser2026-08-22
| | | | | | | | splash_ms and the project settings join the knobs table, the pane-state ranking gains the floating it was missing, contrib/themes' seventh file is named for what it is, the shader table's numbers are called examples with the real omitted-value defaults beside them, the index links workspaces, and the verb table learns splash.
* slosh: put-away panes are miniature pane framesuser2026-08-21
| | | | | | | | | | | | Brackets were tried and still read as text. Each min-bar entry is now a three-row chip wearing a real pane frame -- the same corners, because a frame is what a pane *looks like* in this program, so a shrunken one reads as exactly what it is: a pane, clickable. The put-away mark rides inside each chip and the lone legend is gone; a rung pane's bell mark keeps its own colour inside the frame; hover lights the whole chip and the whole chip is the hit. The bar costs three rows instead of one while anything is minimised (and nothing when nothing is), which is what an affordance that works is worth.
* slosh: minimized panes wear buttons in the stripuser2026-08-21
| | | | | | | | | | | A row of dim bare names read as a caption, and nobody clicks a caption. Each entry now wears square brackets -- [▬ name] -- because that is what clickable already looks like in this program: [re-run], [close], the buttons a pane's own row offers. The put-away mark rides inside each entry instead of standing at the head of the row as a legend: a legend explains the row once, a mark per entry says what each button is. A rung pane's bell mark keeps its own colour inside the brackets, and the whole bracket is the hit, as one geometry demands.
* slosh: M10c, the memory -- floats survive being written downuser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A dumped session quietly re-tiled its floats, which was the one data-loss gap left in D22. dump-layout now records `floating=true x= y= w= h=` -- the *wanted* rect, because intent is what survives a session and the next screen's layout clamps it like any frame -- layout files declare the same (linted: floating is a bool, x/y/w/h mean nothing without it), and a file whose every pane floats self-heals through ensure_tiled: the top float lands, the same rule closing the last tiled pane follows, no lint required. save-workspace inherits all of it from the shared dumper. `float` over the API takes x/y/w/h and then *places* rather than toggles -- a placement is a statement about where, not about whether -- floating the pane first when it is tiled, re-placing when it already floats, with omitted fields meaning "keep". The intent is stored exactly as asked: the write-back rule is for hands, whose drags need the clamp to agree with the eye; a script means what it typed. And kitty graphics respect the stack. The cell compositor gets occlusion free from paint order, but placements are sent after the diff and would paint an image straight over a float covering it. Each pane's placements are now clipped by the float rects above it -- every float for a tiled pane, the higher-raised for a float -- cropping what one clean edge can express (the pane-edge source-rectangle arithmetic aimed at another clipper, including from the top and left, where the source origin moves and the sub-cell offset is consumed) and suppressing what it cannot: a strip across the middle or a corner overlap leaves a shape one placement cannot say, and the image returns whole when the float moves. No z= needed -- clipping is the z-order, since nothing is ever allowed to overlap. D22's "still owed" paragraph is paid and rewritten; M10 is fully checked.
* slosh: fold floating panes and the drop grammar into DESIGN.mduser2026-08-20
| | | | | | | | | | | | D22 records the float contract: the minimized pattern (a flag and a wanted rect on a leaf that keeps its seat), derived-vs-remembered rects, the hit list as the z-order, the pop to the centre, the shadow-not-a-state, the backdrop invariant, edge-pinned resizes, and the key decisions -- with M10c (layout files, dump-layout, placement over the API, graphics occlusion) recorded as still owed. M11 extends the drag machine section with the drop grammar: swap centre plus third-width insert bands, the fill as the promise, release derived from the painted hits, and the sibling-reorder rule. The working plan file served its purpose and goes; git remembers it.
* slosh: OSC 8 hyperlinks pass through the compositoruser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | lib-vt tracks a hyperlink per cell and slosh dropped every one of them: the client's terminal can only offer a link it was sent, so `ls --hyperlink`, gcc diagnostics and friends silently lost theirs inside the session. The link rides beside the cell -- cell_t is the shader ABI and a link is not a colour -- as a plane of ids into an interned URI table, append-only so an id in prev_link still means the same URI next frame (which is what lets the diff compare ids instead of strings), and capped so a hostile pane cannot grow it forever. URIs with control bytes are refused outright: they are emitted verbatim inside an OSC, and a control byte in one is a way to write escape sequences into the client's terminal. The diff wraps emitted runs in OSC 8, closes what it opens before the frame ends, and treats a link-only change as a change. Chrome painted over a link takes the link with it, so a frame, modal or float across a URL is never clickable with the pane's link; links survive scrollback with the text that carries them. Plain-text URLs stay the outer terminal's own matcher, with the catch documented in panes.md: under mouse capture Ghostty only offers links with shift added to the gesture (shift+cmd hover/click), shift being its capture escape. Wrapped URLs can never be regex-matched through a multiplexer -- rows repaint as hard lines -- which is exactly what OSC 8 links, passing through whole, are for. `snapshot format:"bytes"` returns the emitter's own output for a frame (a second call is the delta): the model and the bytes are two different things, and this codebase already paid once for asserting on one while the other was wrong.
* slosh: floating panes and the drop grammaruser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane can float: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag and a rect on the leaf, exactly like `minimized` -- the seat in the tree is kept, layout skips it, the siblings absorb its share, and un-floating returns it home. The drawn rect is the wanted one clamped to the tab every frame, never written back, so a terminal squeeze round-trips exactly; an explicit move or resize *does* write back, because the hand is the author of the intent. Floating pops the pane to the centre: the jump is what says it happened. Painted after everything tiled, so the hit list -- searched backwards -- resolves an overlap to the float you can see: the z-order and the click order are the same list. It casts a shadow (float_shadow, one number), and `floating` joins the states table above `unfocused`, so a float is never dimmed as merely-unfocused. The hands: the title moves it, any other border resizes it (a bottom corner on both axes; the grabbed edge pins at walls and floors, never sliding the window), H J K L move a focused float, the =/+ key grows it about its centre, `-` shrinks it (its split was a refusal anyway), and hovering shows which edges a grab would take, from the same derivation the press uses. Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`, where it reads as search); `C-a F` opens a new floating shell -- the throwaway terminal; the function row f1..f12 became nameable in configs along the way. Guards throughout: the last tiled pane cannot float or minimize away, closing it lands the top float, splits are refused with their own words, floats are not swap targets. Dropping got a grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four edge bands meaning "insert me beside this pane, on this side" -- detach_leaf + place_beside, the surgery cross-tab moves already had. Bands are thirds (the first map of quarters showed the swap owning half of each axis), the hovered band fills the half the drop would hand over, the release resolves against the painted frame's hits so it cannot disagree with the fill, and a drop between siblings along the axis is a reorder that always fits -- refusing it was refusing the most ordinary drag there is. Also: rename-pane/rename-tab as palette actions (renaming was a double-click and nothing else, the one verb the palette could not reach), and C-u clears the rename editor like it clears the picker's query. Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
* slosh: layout files are *.layout, not *.layout.kdluser2026-08-19
| | | | | | | | | | | | | | | | | | | | | 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.
* slosh: split app.c into five units, mechanicallyuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | app.c was 7k lines wearing section markers; now the markers are files, cut exactly along them, no behaviour change: app.c the session core: config in force, pane and tab lifecycle, walking, input routing, introspection app_layout.c layout, tree edits, resizing, equalising, rotating, focus app_session.c layout files, checking, workspaces, dumping a session app_draw.c drawing, the shader passes, kitty graphics app_ui.c the finder, modals, the palette, the project picker, renaming include/app_internal.h carries what the five share: the tree/tab/app structs, CFG and the palette macros, and the 50-odd formerly static functions the cut made cross-unit, declared where they are defined. They keep their names; only the five units see the header. One test moved with the code: test_portable_layouts' staleness check greps the source for the layouts section, which now lives in app_session.c. Mechanical, so blame for anything in the four new files continues in app.c before this commit.
* 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: aim the top handle at the middle, and stop ruling through the rowuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two faults that fed each other, and only the second one made the first visible. The handle went to the **widest** free run of the top row. A centred title leaves two runs of nearly the same width, so a one-cell difference nobody can see threw the handle out toward a corner: on a 116-column pane whose row middle is 60, it sat at 85. Placement should be stated in terms of where the thing wants to be, not of the leftovers it is allowed to occupy -- so it now aims at the middle of the row and slides into the nearest run that is free. Nothing in the way: centred, like the other three sides. A title holding the middle: hard against the title, where the eye already is. That same pane now puts it at 64. The armed edge also ruled the heavy line straight across the whole border, over the pane's name, its buttons, its scroll indicator and a dead pane's epitaph. The row went unreadable exactly while the pointer was on it -- and the title the handle had just been placed beside was wiped, which is what turned a merely-off position into an apparently random one. Each cell of the edge is now painted only if the hit list says the edge still owns it: the same question the handle asks, and the one the click will ask. The epitaph had to start claiming its cells to take part. It answers `panestatus:` and does nothing when clicked, because an epitaph is not a button; what it buys is that ` exited: status 3 ` survives a hover over the border it lives on, next to `[re-run]` and `[close]`, which already survived because they were buttons. Verified: `make retest`, **1877 checks measured**, all green. Four new: the handle is centred on the row when the row is empty, sits against the title when the title holds the middle, and the armed top edge leaves both the name and the three buttons legible. Live on a 116x36 pane, hovering the far left of the top row -- deliberately the rim, so this is the first stage doing all of it: ╭━━━━━━━━━━━━━━━━━━━ sl0ppty ███▲███━━━━━━━━━━━━━━━━━▬ □ ✕ ╮ and on a dead pane, hovering the bottom handle: ╰ exited: status 3 ━━━━━━━━━━███▼███━━━━━━━━[re-run]━[close]━╯
* sl0ppty: the split arrow belongs on the buttonuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The arrow rode the middle of the dashed boundary line, which put the two halves of one message in two places: the handle said *press here* and a glyph out in the middle of the pane said *this is what happens*. It also meant the direction was unreadable until you were already on the target, since the dashed line is stage two of the hover. So the arrow moves into the handle and the boundary line goes back to being a plain boundary -- which is the honest shape for it anyway: it is the *edge* of the pane you are about to make, and an edge has no middle worth marking. The handle is now one object that explains itself, and because the handle is drawn from the first stage, brushing any part of an edge tells you where its button is and which way that button splits, without previewing a boundary nobody asked about. Knocked out with ATTR_INVERSE rather than drawn in a second colour: the bar is solid `guide`, so a `guide` glyph on top of it would have been invisible, and inverting gets contrast that follows whatever colour the theme sets for no new knob. Along the handle the arrow sits in the middle, where the pointer is aiming. Across it -- a distinction only the upright pair has, being the two that grow a second cell -- it sits on the edge's own cell rather than the one over the content, because it marks a boundary and the boundary lands on the frame. Verified: `make retest`, **1873 checks measured** (`test_split_ux.py` 100 of them), all green. The test that pinned the arrow to the dashed line is now the test that pins it to the handle, on all four sides, plus one that it is legible from the first stage with no boundary drawn. Checked live on a 116x36 pane: `◄█` at column 2 and `█►` at column 117 on the middle row of their bars, `━━━███▼███━━━` on the bottom border's single row, and -- the case that shows why this is worth it -- hovering the *far left* of the top row draws `███▲███` at column 85, where the handle actually is, having dodged a centred title. A note on the two commits before this one: their messages quote 1801 and 1807 checks, which were carried over from earlier in the session rather than measured. The figure was 1865 at that point. The code and the tests were right; the numbers in those two messages were not.
* sl0ppty: a horizontal handle gets heavier, not talleruser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | The armed handle grew by one cell into the pane on all four sides, which is one change on two axes and therefore two different results. A cell is about twice as tall as it is wide -- the fact `gap_aspect` exists for, and the one the split picker measures its longer side by -- so the extra column on a left or right edge read as a thicker line, and the extra row on a top or bottom edge read as a wall. It also spent a row of somebody's output to say something the glyph was already saying: `█` is the heaviest thing one cell can draw, and against `━` it needs no help. So only the upright sides widen now. A horizontal handle stays on its own row. That also removes its claim on a content cell, which is why the inner-strip hit is registered on those two sides only -- the paint and the hit still describe the same rect, which is the whole reason the guide registers what it thickens. Verified: `make retest`, 1807 checks, all green -- six new, pinning the asymmetry in both directions: the top and bottom handles occupy exactly the row their border is on, the left and right ones exactly two columns, and the inner column of each upright handle hit-tests as that border rather than as the pane. Measured on a live session rather than argued: hovering the bottom border of a 116x36 pane puts seven block cells on row 37 and no others, inside the `━` rule; hovering the left border puts them on rows 16-22 across columns 2 and 3.
* sl0ppty: the middle of a border is the button, not all of ituser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a pane's border to split toward it is the right gesture and was the wrong target. A border is the longest thing on the screen and it sits between two things you did mean to click -- a title, a gap, the pane beyond it -- so reaching a cell too far changed the layout, and the top row was worse than the sides: clicking a pane's header to focus it split it upward. So the target shrank. Each side carries a *handle* of three to seven cells in the middle, and only the handle splits; the rest of the edge is the *rim*, which arms the guide and does nothing when pressed. Three is the floor the pane buttons already settled on for the reason written there -- a one-cell target is a thing you miss with a mouse -- and seven is the ceiling, because a third of a tall pane's border is most of that border again and brings the accidents back with it. Hover now answers two questions in two stages, because they are two questions. Anywhere on an edge, the edge goes heavy and the handle thickens by a cell into the pane: that says where the button is, which is the thing you can no longer infer. On the handle, the dashed boundary and the arrow as well: that says what the click will do. The hint follows the same split, so sweeping an edge neither draws a boundary nor offers a split you cannot get from where the pointer is. Three things this had to get right. **The thickened handle is registered, not just painted.** The guide adds the hit for the inner strip in the frame that draws it, and reads the outer rect back from the hit list rather than recomputing it, so there is one opinion about where the handle is (D6). A handle you can see and cannot click would be worse than either mistake alone. **The top row cannot put its handle in the middle.** The buttons own its right and a centred title -- the default -- owns exactly the middle. The title winning is the existing rule, and it is what keeps a double-click rename from splitting twice on its way, so the handle is placed last, in the widest run of cells the row has left and nearest the middle among equals. A row with nothing left offers no upward split; its other three edges are unaffected. Each handle cell is then painted only if the hit list still agrees it belongs to the handle, so a block never lands on a pane's name. **The row keeps the drag and loses the split.** `title:<id>` takes no side now and `title:<id>:t` -- the handle -- carries one, which is why the press path reads the side out of the action instead of assuming it. Press-and-hold skips the dwell on a handle, where a press means something, rather than anywhere on an edge. Verified: `make retest`, 1801 checks, all green. Nine of them are new or rewritten: the two-stage guide, the caption belonging to the handle, the rim splitting nothing on any of the four sides, the top row's handle still splitting upward on a titled pane, and the rim of the top row not. Tests read the handle's cell out of the hit list (`Snapshot.handle`/`.rim`) rather than deriving the geometry, so a test cannot agree with a broken formula. Also checked by hand on a live session, which is where the placement rule earned itself: hovering the rim draws the heavy edge and the block and no dashed line, hovering the handle adds the line and the arrow, three clicks on pane headers split nothing, and on a pane titled `sl0ppty` the top handle lands to the right of the title and left of the buttons -- `drag to move · click to split up` at x=42, `drag to move` either side of it.
* sl0ppty: make tools, and a ruff version that cannot driftuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `ruff` is not a system package on most machines, and `pip install --user ruff` is refused outright on a distro python (PEP 668) -- so the last two commits shipped a formatter nobody could run without knowing to build a venv by hand. `make tools` builds it: $ 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 if it is missing rather than pretending it handled it. **The venv is looked in before `$PATH`, and the 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 who has 0.14 from their package manager and 0.16.3 sitting in the `.venv` the repo just built for them -- refused by the tool, with the fix already on disk and not being looked at. Tested with exactly that: 0.14.0 on `$PATH`, `make fmt-check` still clean. 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. Refusing loudly is better than reformatting quietly. Bumping it is one line in `ruff.toml`, one in the Makefile, and a reformat commit, which is 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 "not installed" message now says `make tools` instead of naming three package managers and hoping. Verified from the state a fresh clone is in: with no ruff anywhere, `make fmt` says `ruff not found. run: make tools` and stops; after `make tools` (2.6s) both `fmt` and `fmt-check` work with nothing on `$PATH`; the hook formats a staged Python file using the venv; the venv survives `make clean`; and `.venv/` is ignored. `make fmt` over the whole tree after all this changed nothing, which is the useful part: the pinned version agrees with what is already committed. One near-miss worth recording: the `.gitignore` line for `.venv/` had to be written twice. A `git reset --hard` undoing a throwaway probe commit took the working tree with it, including that edit -- which is what `--hard` does, and why the state was checked afterwards rather than assumed.
* 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: a dead pane says what it ranuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ``` │help: find a package to install `watch` with `cargo search cargo-watch` │ │ │ │[ran: cargo watch -x test] │ │[process exited: status 101] │ ╰ exited: status 101 ─────────────────────────────────[re-run]─[close]─╯ ``` `[re-run]` is one button on a pane whose program is gone, and nothing on the pane said what it would run. The command was never anywhere: not in the frame, not in the backlog, not in the exit note. Worst in a tab a project's layout built, where the command was never typed into that pane and so cannot be scrolled back to either -- which is now the common case, since that is what `open-workspace` does. So the death note gains a line above it. A pane running the session's shell has no command to name and gets the exit line alone, as before: `[ran: /bin/sh]` would be noise dressed as information. One note rather than two calls. `pane_note` puts a blank line either side of what it writes, so two of them would space the two halves apart as if they were about different things, when they are two facts about one run. Re-running keeps the previous run above it (D14), so the pair turns a pane you re-run into a log of what ran rather than a pile of identical epitaphs. Not put in the frame footer, which is the other place the question gets asked. It already carries the exit words and competes with the buttons for width, so a long command would arrive truncated in the one place that is always visible -- and the note is on screen anyway in the case that matters, because a pane whose program just exited is scrolled to the bottom. **Three existing tests were counting a string that now appears twice.** `DIES_CMD` is `echo mark-one; exit 3`, and they waited for `screen().count("mark-one") == 2` to mean "it ran twice" -- but the note contains the command, so every run now matches twice and the wait never finished. They ask `output_runs()` instead, which counts lines holding the marker that are not the `[ran:` line. Per line rather than by trimming the text, because every row still has the pane's frame border on the end of it. That is a fair correction and not a weakening: the assertion is still "the output appeared twice", which is what they meant. Verified: 56 checks in `tests/test_dead.py`, 1801 overall, all green. Three new: the command is named and sits *above* the exit line (asserted by row order, not by reading the two strings out of one screen); a pane with no command names none; and two runs produce two of each note. Then a real session, `command="cargo watch -x test"` with cargo-watch not installed, which is how this was noticed.
* sl0ppty: double-click a word to select ituser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pane_select_word()` was written and never wired to anything: no declaration, no caller, no way to reach it. This is the rest of it -- the gesture, the setting, and the tests. **The word is defined by what it stops at, not by what it is made of.** Anything that is not blank and not a listed separator belongs to a word, so CJK, accented letters and emoji need no table and come out whole; whitespace always separates, listed or not, because a double-click that selected the line would be a different gesture arrived at by surprise. Wide glyphs continue the run they are in rather than each being their own word -- otherwise every CJK character is a word. `word_separators` defaults to `"'`()[]{}<>;,|`, and what it leaves *out* is the decision: `/ . - _ : = @ # % ? & + ~ $` stay inside words, so a path, a dotted name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are each one thing you double-clicked to copy whole. `:` is the difference between copying an error location and copying half of one, which is why kitty keeps it too. The gesture reuses what selecting already has: the press starts a selection at the cell -- which anchors a drag out of the double-click -- then widens it to the word, and the release path that already copies a drag copies this. So the word *is* the copy, in the one place that rule lives, and the highlight is the copied span rather than an approximation of it. Tracked per cell as well as per pane, so clicking two different words quickly selects the second rather than everything between them. A program that asked for the mouse still keeps its clicks, with shift as the escape hatch, exactly as the drag has it. **One thing this needed on the way: quoted values were dumped unescaped.** The default separator set opens with a quote, and `cb_add(&b, "key \"%s\"\n", ...)` wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking the property the dump exists for. The escaping loop already existed inside `cb_chord`, for the same reason and with the reasoning written down; it is now `cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`, `editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those, load-bearing for this one. **And `--dump-config` does not do what four comments and the docs said it did.** It calls `config_dump_defaults()`: every setting with its *default*, never the value your config gives it. The function name was the honest one. Corrected in `docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's own docstring, rather than changing what the flag does -- starting a config from it is the documented use, and `--check` is what reads yours. There is no way to ask for the effective config; that is a gap, not a lie, and it can be a flag when somebody wants it. Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight being exactly as wide as the copy; a separator, a blank cell and a single click each leaving the clipboard alone; two clicks on different cells not being a double; two slow clicks not being one; the separator set honoured from a config, including a quote and a backslash carried through KDL escaping; a word in a scrolled pane coming from the row on screen; and a mouse-tracking program keeping its double-clicks until shift takes them back. Three of those failed first because the test asked for the answer twice -- a second `worded()` call is a second pair of clicks -- and one because it mapped string indices to columns without counting wide glyphs as two.
* sl0ppty: `set-name target="pane"`, so a script can overrule a program's titleuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | A pane's name already outranks the title its program sets -- `pane_title` prefers the name while there is one -- and that is the whole remedy for a program announcing something it stopped meaning: an agent still spinning the summary of a task it finished, a build script that titled itself once. The remedy was reachable by double-clicking the title on somebody's screen, and from a layout file, but not from the socket, while `set-purpose` next to it has taken `target:"pane"|"tab"` all along. `target` defaults to `tab`, which is what this verb has always meant. Two verbs whose defaults differ is a smaller wrong than a verb that quietly changes its subject under scripts already written against it. The refusals say `no such pane` or `no such tab`, so a mistyped target shows up in the reply rather than as a label that did not move. The gesture now calls the same function, so the two ways in cannot disagree about where a pane's name lives -- there is a test that a name set over the socket is the name the double-click editor offers to edit. Verified: eight checks in `tests/test_pane_title.py`. A pane named while its program keeps setting OSC 2 titles keeps the name; clearing it hands the label back to what the program calls itself *now*; `id: 0` is the focused pane and leaves the other one alone; `set-name` with no target still renames the tab and leaves the pane's title alone.
* sl0ppty: a skill for driving it, and the two variables that made one possibleuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `.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.
* sl0ppty: workspaces, and layouts that write themselvesuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **Identity is the tab's `purpose`** -- `project:<name>.<hash8>` of the resolved path, the shape D3 already published -- so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `label` only ever knew what a layout said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell -- which made arranging a project and writing it down two jobs instead of one. The kernel knows what the label does not: a pty has a foreground process group, and that group is by definition the job that owns the terminal, so the answer is read the way `live_cwd` already reads `/proc/PID/cwd`. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal), and an ephemeral pane. A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" was true of the machinery and quietly false of the size: handed no limit, lib-vt picks its own -- 10,000 *bytes*, which measured at 622 lines of an 80-column pane, less than one `make` run. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
* sl0ppty: `C-a Enter` splits the longer wayuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One key for "I want a pane and I have no opinion about where". It cuts across the pane's longer side, so the two halves come out as square as the pane allows, and pressing it four times on a 100x30 terminal tiles the tab the way you would have done it by hand: a column, then rows inside the new column, then rows again, then a word saying there is no room left. "Longer" is a question about what you can *see*. A cell is about twice as tall as it is wide, so 80x24 is a wide pane (80 against 48) while 40x24 is not (40 against 48). `gap_aspect` is already this program's answer to how many columns a row is worth, so that is the number used rather than a second constant meaning the same thing -- and a config that says cells are square gets a square answer. There is a test at 74x44, the size where the two readings disagree: square cells split it into columns, the default splits it into rows. The fallback is the other half of "sensible". A pane that is wide but ten rows tall wants cutting across; if `split_fits` says that will not fit, the other axis is tried before giving up. And it says which way it went -- `split right` or `split down`, the same two words the border click already uses -- because one key that can do two things has to tell you which it did. **`split_fits` had a hole this action made obvious.** It only ever asked about the dimension being divided, so a pane four rows tall on a short terminal "had room" for two columns: the width was fine, and the two panes it produced were immediately collapsed into a list by the layout. The border was offering that, and both split keys were doing it. It now also requires the axis it does *not* divide to clear the floor, because splitting cannot improve that one -- the same hole `layout_node` closed at the leaf, one level up. On a 100x8 terminal all three now say "no room to split" instead of undoing the arrangement. Verified: nine checks in `tests/test_split_ux.py` -- the axis chosen for wide and for tall panes, the aspect deciding at 74x44, both fallback directions, the refusal and that the explicit keys refuse the same way, and that it appears on the cheatsheet under `enter` and in the palette by name. The existing 65 checks about what the border offers still pass, which is what I was most worried about.
* sl0ppty: layouts are `*.layout.kdl`, and each loader says which it gotuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
* sl0ppty: docs that match the programuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Audited every page against the source and the binary rather than against my memory of it. Eight pages changed; the lists were all fine and the *numbers* were all wrong. Checked mechanically, and each of these came out complete: all 37 action names, all 28 control verbs, all 9 shader names, the expression language's 10 variables, 2 constants and 7 functions, the 8 pane states, every knob the dump prints, every OSC verb, every internal link and anchor, and every page's place in `docs/nav`. Every fenced `kdl` block was run through `--check`; every `{"cmd":...}` in an example is a verb that exists. What was stale: - **`make` does not build.** The default goal is `help`, so the install block -- `make vendor`, `make`, `make test` -- never produced a binary. It says `make all` now, timed at 5.5s from clean rather than "about a second". - **700-odd checks in about eight seconds** is now 1,500-odd in about thirteen, plus 260 from the C unit tests, which the sentence did not count at all. - **About 10k lines of C** is 15k. (10,799 of those are neither blank nor a comment, which is presumably where the old figure came from.) - **Seven chrome presets** are thirteen, and the six new ones use `sin` and `TAU` -- so the perimeter section gained the angle idiom it now depends on, and its "two things fall out of it" became three. - **`--check`'s example transcript showed two errors nobody can produce any more**: `bad prefix: C-a` and `bad key: ←`. Both inputs became *valid* -- `C-a` when the chord parser learned the notation the cheatsheet prints, and `←` when it learned the arrow glyphs, precisely so a chord copied off the screen parses. Replaced with a transcript I generated, and a note that the problems go to stderr, which matters for the pre-commit hook the page suggests. - **`bell_indicator`** was a real knob documented nowhere. - **"There is no key for this yet"** about moving a pane between tabs, two commits after the keys and the drag landed. - The cheatsheet's `+N more` line, the finder's `C-u`, and `tab_id` in a pane report were all real and unmentioned. Verified: the whole suite green, `make docs` builds nine pages, and the changed pages read correctly in a browser -- including the new transcript and the `>` `<` row of the key table.
* sl0ppty: drag a pane onto a tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Drop it on a tab in the strip and it moves into that tab; drop it on the `+` and it gets one of its own. The fourth verb of the same drag machine, and it cost almost nothing because it is the third one's target read the other way round: the strip was already a row of drop targets, and a pane in your hand was already a thing being carried somewhere. The strip draws the drop states itself. `ptr_on` reports nothing during a drag -- on purpose, so hover feedback does not fight a gesture -- so a tab cannot find out it is under the pointer the usual way. It uses the two states the panes already use for this exact question: every tab the pane does not live in is a candidate in the drop colour, and the one under the pointer is filled. These are all targets; that is the one you are on. The tab the pane already lives in does not offer itself, because dropping there is the one refusal a drag can reach -- and it says "it is already in that tab" rather than looking like a drag that failed to take. Leaving the strip takes the offer back, the same way dragging a tab off the strip is not a cancel but simply not a place to put one. Releasing over a pane still swaps the two, which has a test of its own: the strip is a new kind of destination, not a replacement for the old one. A toast says where the pane went, and here it matters more than for the keys: the pane lands in a tab you are not looking at, so without a word the screen just shows one fewer pane. Verified: `tests/test_move_pane.py` at 52 checks. The new ones drive real mouse reports -- press on a title, motion onto a tab hit, release -- for the move, the `+`, the three highlight states (candidate, filled, and the pane's own tab declining), taking the offer back by dragging off the strip, the refusal, and that pane-onto-pane still swaps.
* sl0ppty: `C-a >` sends this pane to the next tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C-a > push it to the tab after C-a b into a tab of its own C-a < push it to the tab before Three actions rather than three special cases in the key handler, which is what puts them in the cheatsheet and the palette without another line of code -- and the palette is where you find a thing you did not know to look for. You do not follow the pane, and a toast says where it went: "moved to tab 2 (api)". A key you might press twice needs to say what it did, and following would take you away from what you were doing. The tab's *name* is read before the move and its *number* after: emptying this tab removes it and shifts everything along, so the number read first is not the number anybody will see. When the tab you were in is the one that goes, you arrive in the survivor because there is nowhere to stay. `>` and `<` are the shifted period and comma, and the defaults bind both halves -- a terminal does not report shift for punctuation, so `>` arrives as a bare period the way `?` arrives as a bare slash. The config loader already did this for a chord you write by hand; the defaults had to do it themselves. The cost is that `.` and `,` are spoken for, which is the cost `/` already pays for `?`. `b` is for break out, the word tmux taught everybody for this. **Two things the tests caught, both mine.** `panes` reported a pane's tab as an index while every command that takes a tab wants an id -- and my own test conflated them, passing only because the two agree until a tab is removed, which is half of what these keys do. There is now a `tab_id` beside the `tab`: one is where it sits in the strip, the other is what you pass back. And three more rows overflowed the cheatsheet: `modal_frame` clamps the box to the screen, so at 56x44 the `session` group was simply not drawn. The sheet now says `+7 more, in docs/keys.md` when it cannot show everything -- counted by walking the rows it skipped, because arithmetic on two uneven columns claimed six were hidden on a screen showing all of them. A list that quietly stops is worse than a short one: the reader cannot tell that the key they wanted was on it. Reflowing to two columns when the height forces it would be better than a footnote, and is not this commit. Verified: `tests/test_move_pane.py` at 38 checks -- the keys in both directions, breaking out, the refusal when there is only one tab, pushing the last pane out and landing in the survivor, and that the sheet prints `>` rather than the bare period it also answers to while the palette finds it by label. `tests/test_help.py` gained the short-terminal case and lost an assumption about its own fixture height.
* sl0ppty: a pane can change tabsuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}' The mechanism only. There is no key and no gesture for it yet -- that is the next conversation, and guessing at it now would mean building the wrong one twice. **The pane keeps running.** Measured, not assumed: `pid=3062557` before the move and `pid=3062557` after, in a different tab, with its scrollback intact and still answering what it is sent. That is the whole reason to move a pane rather than open one where you wanted it and close this one. It is tree surgery and it reuses the surgery that was already here. `place_beside` is the second half of `split_node_with` -- everything after "make the leaf" -- lifted out so that a leaf which already exists somewhere else can be grafted the same way, flat sibling or new split, on either side. It takes the tab rather than `cur(a)` now, because replacing a root has to land on the right one and `cur(a)` was only right while the sole caller was splitting the pane you were looking at. `detach_leaf` is `close_leaf` without the funeral. Three decisions worth naming: **The destination is a tab id, not an index.** A tab whose last pane leaves has nothing to be, so it is removed -- and every index after it moves. An id survives that; an index quietly means a different tab. The move re-finds the destination by id *after* the removal for exactly this reason, and there is a test that empties a tab and checks the pane landed where it was sent. **A move drops what the old tab thought about the pane** -- a zoom naming it, and its minimised flag. Both are the tab's opinion and not the pane's: carried across, the first zooms a pane that has left and the second files the arrival in a strip nobody asked for. **It does not follow the pane.** Which tab you are looking at is a question about intent, and this is the mechanism; `move-pane` answers with the tab id so a caller can select it if that is what it meant. "Into a tab of its own" is its own call rather than new-tab-then-move, because a pane that is the only thing in its tab would otherwise have its tab removed from under the new one, and the order those two happen in is precisely the bug. Verified: `tests/test_move_pane.py`, 25 checks -- the pane still running and still talking afterwards, placement beside and under the destination's focus, focus landing on the arrival, the emptied tab, a tab of its own, every refusal (no such pane, no such tab, already there, already alone), the dropped zoom and minimise, and after three moves a layout with no overlaps that dumps and rebuilds as itself.
* sl0ppty: the prompt speaks config, not a dialect of ituser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> include "contrib/chrome/heartbeat.kdl" heartbeat.kdl: 1 chrome, 0 content chrome> shaders { ...> tint where="chrome" color="#ff5fd7" amount="abs(sin(t / 5))" ...> dim where="content" amount=80 ...> } 1 chrome, 1 content Both of those were errors an hour ago, and the second one was my fault twice over: `:paste` printed `include "..."` and printing something you cannot type back is a prompt lying about its own output. Pasting it answered `unexpected argument for include`, and `load "..."` answered the same about `load`. So the grammar is now the config's, all of it: an entry, several with `;`, an entry with its own `where=`, a whole `shaders { }` block over as many lines as it takes, `include "f.kdl"`, `load f`. There is a test that reads `:paste`'s block off the screen, clears the pane, types it back line by line and compares the cells -- round-tripping is the property, not a nice-to-have. **The text is a document.** What it says replaces both chains, exactly as naming `shaders { }` in a config replaces the block rather than adding to it, and an entry's `where=` decides which rect it lands on. That drops a rule I invented last week -- "one chain, one rect", which refused an entry that named the other one -- because it made the prompt's mode a law rather than a default and made a two-rect block, the thing `:paste` prints, impossible to paste back. The prompt now keeps the document and sends all of it, so a line aimed at the frame leaves the contents alone, and the reply says `1 chrome, 1 content` rather than `ok`: with routing visible, the counts are the answer worth reading. `route_entries` is the one place routing is decided now -- config file, preset file, typed line, same function. `config_parse_chain_doc` takes the wrapper or the bare entries, because both are things a config contains and therefore both are things somebody will paste. Still nothing in the prompt parses KDL. A file goes by path; a pasted block goes via a temporary file, since an OSC payload cannot carry a newline -- a control byte ends the sequence, by the spec -- and joining lines with `;` would need exactly the comment-and-quote scanner I refused to write. The only syntax the script looks at is counting braces to know whether you have finished typing, and an empty line gives up on that. One hint, for the mistake that started this: when the session says `unknown shader`, the prompt adds `(a file? :load <path>, or include "<path>")`. After the fact rather than before, so a plugin's shader name is never second-guessed. Verified: `tests/test_shader_repl.py` is 41 checks -- every input shape, the multi-line paste and giving up on one, the round trip, the hint -- and `tests/test_inband_shaders.py` gained the document rule in both directions, including that a document naming only the frame takes the contents pass off. Full clean suite green.
* sl0ppty: `:load` a preset, without a second KDL readeruser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> :load sine-comet sine-comet.kdl: 1 chrome, 0 content chrome> :paste // sine-comet.kdl include "/home/user/dev/sl0ppty/contrib/chrome/sine-comet.kdl" A path, or the bare name of anything in `chrome/` or `shaders/` beside the script, from any directory. Entries land wherever their own `where=` says, so one file can fill both chains and a preset goes where its author put it. Tab completes filenames after `:load` and vocabulary everywhere else. **The prompt does not parse the file.** I started writing the scanner -- find the `shaders {` block, count braces, strip `//` outside quoted strings so that `color="#ff0033"` and `t / 8` survive -- and stopped, because that is a second reader of a format that already has one, and the two would disagree exactly where comments meet strings. Instead the session gets the path and reads it with the parser it already has: `config_parse_chain_file`, built on the same `parse_shader_entry` a config file goes through. Anything that is not a top-level `shaders { }` block is ignored, so a whole `config.kdl` can be handed over and only its shaders apply. The alternative was regenerating all 45 presets into something easier to read from Python. That would have cost them what makes them useful: each one is a config fragment today -- pasteable, `include`-able, checkable with `sl0ppty --check`, and loaded as a config by `tests/test_chrome_presets.py`. A format only the prompt can read would be a fourth thing to keep in step. `:paste` after a load answers with an `include` line rather than the file's thirty: the file is on disk, it is already the block, and reprinting it scrolled the pane you were looking at -- which is how I noticed. Both chains are built beside the live ones and swapped in together, so a file that goes wrong half way leaves the pane as it was rather than wearing the half that parsed. Each compiled expression is matched to the chain whose shader points at it before either is installed; freeing a program the other chain still uses is the one bug here that would not appear until the next repaint. Verified: nine checks in `tests/test_shader_repl.py` -- a preset by bare name, a file with both rects going to both chains, the three ways to be wrong (no such file, no `shaders { }` block, no argument), what `:paste` says afterwards, and tab completion. Then all 45 shipped presets loaded by name in one session: none refused.
* sl0ppty: PI is 180user2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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".
* sl0ppty: sin and cosuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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.
* sl0ppty: bitsuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `&` `|` `^` `~` `<<` `>>`, 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`.
* sl0ppty: three ways back from a painted paneuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;\033\\' # from the program sl0ppty cmd '{"cmd":"clear-shaders","id":0}' # from outside it bind "z" "clear-shaders" # from the keyboard `app_clear_pane_shaders` empties both of a pane's chains and says whether there was anything there, which is what lets a key answer "shaders cleared" or "no shaders on this pane" instead of maybe having done something. In-band it is not a new verb: naming no rect means all of them. `shader;` sits under `shader;chrome;` and `shader;content;` as the degenerate case of "which rect", so a program puts a pane back in one exchange and cannot leave it half done. `contrib/shader-repl`'s `:both` now uses that rather than two. **None of the three is gated on `in_band_shaders`.** A chain outlives the setting that allowed it -- paint a pane, then turn the setting off, and the paint is still there, because a chain belongs to the pane and not to the file (there is a test that reloads with it off and clears anyway). A way out that the setting can take away is not a way out. The operator path matters for the same reason: the program that made a pane unreadable is not always in a state to put it back. They clear what the *pane* set. The config's chains and the session's own dimming are not this pane's doing and are derived every frame anyway. The action is bound to nothing, like `scroll-up`: it is in the palette, which is what the palette is for, and the docs say to bind it if you leave the setting on. Which is where a second bug turned up -- the palette truncates a label at 26 characters, so "undo this pane's own shaders" read as "undo this pane's own shade" on screen. Renamed to fit, and `tests/test_palette.py` now checks every label against the column, since nothing failed for that: it just looked like a typo in front of someone.
* sl0ppty: a prompt with a memoryuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `contrib/shader-repl` imports readline, which is most of it: editing, up/down, ctrl-r, and history kept between runs in `$XDG_DATA_HOME/sl0ppty/shader-repl.history`. Chains only -- a `:` command is taken back out of the history it would otherwise fill, because pressing up four times past `:quit` and `:paste` to reach the line you were working on is the annoyance history exists to remove. Tab completes the commands, the nine shader names, the property keys, the two rects, the three channels, and the expression language's own variables and functions. `:help` prints the lot at once, which is the same list from the same place. That list is a claim about what exists, so `tests/test_shader_repl.py` checks it against `src/shader.c` and `src/expr.c` and fails if either grows a name it does not have -- verified by removing `spotlight` and watching it fail. Then it types every name it offers at a real prompt and requires `ok` from a real session, because a name that completes and does not parse is a lie with a keystroke attached. History across two runs, a tab completion, and `:help` are driven the same way, through a pty. Two things that cost me time and are now written down where they bite: keys go into a pane escaped (`\r`, `\t`, `\e[A`) because `raw` takes the rest of one command line and a literal newline ends the command instead of arriving; and Enter is CR, which is what a terminal sends and what readline waits for. Also: **a bare argument on a shader entry is now a complaint.** Every parameter is a property, so `tint color="#000000" spotlight` is not a pass with a spare word, it is a missing `;` -- KDL reads one node, the second name does nothing, and the session used to answer `ok`. It now says `unexpected argument for tint: spotlight (separate entries with ';')`. Found by watching the prompt get used.
* sl0ppty: a pane can set its own shadersuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"\033\\' The field after `shader` is which rect -- `content` or `chrome` -- and the rest of the payload is a chain in exactly the syntax a `shaders { }` block uses, taken verbatim so that `;` can go on separating entries. Empty text clears that chain. The session answers on the program's stdin, `shader-reply;ok` or `shader-reply;error;bad amount for tint: ...`, because a prototyping loop that gets no answer cannot tell a typo from a build without the feature. `contrib/shader-repl` is the prompt this exists for: type a chain, watch the pane, and `:paste` prints what you have as a config block. Both ends are the same parser, so what you arrive at is what you can keep -- verified by pasting `:paste`'s output into a file, linting it, and checking the frame comes out the same colour. **D13 said this was impossible on purpose, and the reasoning stands**: a program that can restyle the session it happens to be running in is a hazard, and `cat hostile.txt` should not dim your panes. But *impossible* was the wrong shape for it -- editing a file, saving, looking and guessing again is slow enough that shaders go unwritten. So it takes `in_band_shaders true`, one line of consent, and it is scoped to the pane that asked: its own two chains, never its neighbour's, never what the config said about anyone else. There is a test for the neighbour. Each chain owns the programs its expressions compiled to, and is swapped in whole, so a chain that turns out not to read leaves the pane looking like it did rather than half-restyled. Measured: 1,800 replacements of an animated chain, resident size flat after the first pass. Also here, because it is the same paint order: the chrome chain now sits between the config's and policy's, exactly where the content chain does. Dead code out -- `app_shade_add`/`app_shade_clear`/`app_shade_count` had no callers and their comment claimed the opposite of what this commit does.
* sl0ppty: padding, per sideuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | padding 1 // every side padding 0 2 // vertical, horizontal padding 2 0 0 1 // top, right, bottom, left One value, two, or four in CSS order, because that is the order anybody who has written a stylesheet already knows. `pad` becomes four fields and the content rect is deflated per side rather than symmetrically, which is the only real change: everything downstream already worked off `n->content`. The units do not change with how many numbers you write. They are ROWS, like `gap`, and a horizontal one is multiplied by `gap_aspect` on the way out -- so `padding 1`, `padding 1 1` and `padding 1 1 1 1` are one padding rather than three, and a ring that looks square is still one number rather than an arithmetic problem. Anyone who wants plain cells sets `gap_aspect 1` and gets them everywhere, which is what that knob is for. Three values is refused with a line saying what it wanted. CSS reads it as top/horizontal/bottom, and a line whose meaning you have to look up is a line nobody can read -- and `sl0ppty --check` now has something to say about it, which is the difference between refusing and ignoring. The dump writes the shortest form that means what is in force, so `padding 0` stays `padding 0` and a dump of a dump is still the same dump. Checked by measuring both rects: a pane's insets from its own frame, four numbers, for every form -- including that `gap_aspect 1` turns the horizontal ones into cells, and that a refused padding leaves the rest of the file applied.
* sl0ppty: sl0ppty --checkuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A config linter, on the CLI: $ sl0ppty --check # what a session would read $ sl0ppty --check themes/mine.kdl # or a file you have not installed config.kdl:101: bad prefix: ctrl+nosuchkey themes/mine.kdl:7: unknown shader: bloom ~/.config/sl0ppty/config.kdl: 2 problems $ echo $? 1 A mode rather than a script, because the only checker that cannot drift from what a session does is the loader a session uses. The single difference is how many problems each reports: a session has one status line and shows the first, and a linter that stopped at the first mistake would make you run it once per mistake. Exit 1 when it has anything to say, so it drops into an editor's compile step or a pre-commit hook with no glue. A clean run prints what it read -- the files, including everything `include`d, the prefix it ended up with, how many bindings -- because a config that was never opened and a config with nothing wrong otherwise look identical, and "it printed nothing" is not evidence. An optional path so it can lint a theme before you install it, and $SL0PPTY_CONFIG is honoured when there is none, so it checks the same file the session will. tests/test_check.py covers the contract: exit status both ways, every problem reported rather than the first, file and line on each, an included file naming itself, a file that will not parse saying nothing applied, a missing file being a problem rather than a crash, and the one that would have caught the bug in the commit before this -- a config written by `--dump-config` lints clean.