aboutsummaryrefslogtreecommitdiffstats
path: root/docs/scripting.md (follow)
Commit message (Collapse)AuthorAge
* osc 7501: say what you are doing in somebody else's protocoluser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | OSC 5577 only works for programs that know slosh exists. The program status protocol is the same idea written down for everybody, by ghostty's author and implemented by ghostty and Rex, so a program that already speaks it needs to know nothing about us: printf '\033]7501;state=working:app=cargo:msg=YnVpbGRpbmc=\033\\' Implemented *as well as* 5577, not instead of it. 5577 is a two-way control channel -- buttons answer on the program's stdin, shaders are refused with a reason -- and 7501 deliberately is not, so neither absorbs the other. What 7501 buys is the programs we do not control. The scanner becomes one pass for four things: 5577, 7501, OSC 133 ; A and RIS. The last two are here because 7501 ties record lifetime to them -- a new prompt or a full reset is how a stale `working` dies without a heartbeat -- and because a pane should pay for that scan once. osc5577_fn gives way to osc_scan_cbs_t. Reports are all-or-nothing, which is the spec's rule: no state, an unknown state, a broken limit, base64 that will not decode, a control character in decoded text or a malformed id discards the whole report rather than writing a partial one. A malformed *pair* is skipped and the rest parsed. A pane keeps up to PANE_PS_MAX records with LRU eviction, and composes the one line it has from them by precedence -- blocked, error, done, working, idle -- resolving `app` from the nearest ancestor that has one, the way the hierarchy is allowed to mean something. It lands in the slot 5577's `status` already had, because every reader of a pane wants "what is this doing", not "which protocol said so"; `panes` reports program_state, program_kind, program_app and progress beside it, with -1 for a progress nobody reported, since a bar drawn at 0% says something false. The feature-detection reply is rate-limited here. Note for the next re-vendor: upstream lib-vt implements 7501 too, newer than our pin, and answers the support query itself through write_pty without rate-limiting it. See "What upstream did" under D1 in DESIGN.md before swapping this half of the file for GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS.
* docs: the pane you are in, the pane you name, and not moving the viewuser2026-09-29
| | | | | | | | | | | | $SLOSH_PANE in the man page's environment list, next to SLOSH and SLOSH_SESSION, and the scripting guide grows the section that follows from it: read yourself with `capture`, build with `apply-layout focus:false`, place with `move-pane beside`, and never read `focused` as "where do I live". `pid` is written down as the answer for anything that cannot read its own environment. (Three roff \(em escapes had got into the markdown table; they are em dashes like the rest of the file.)
* theme: the busy spinner is its own colour (tab_status_spinner)user2026-09-22
| | | | | | | | | | | | | It was drawn in tab_status_busy, the colour of the words it sits beside, which made one decision out of two. The words have to stay readable at a glance and a mark does not: a theme may well want the glyph loud and the text calm -- or the text in the accent and the mark a quiet dot -- and with one entry neither is available without moving both. Unset it follows tab_status_busy, derived after it and from whatever that resolved to, named or derived, so nothing changes for anyone who does not ask and no theme in contrib needs a line. `--dump-theme` writes both, like every other colour the compositor draws.
* osc 5577: `busy`, and a sidebar that shows it (busy_mark)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A status is a line of text, and a line of text cannot say whether it is still true. "building 3/7" reads exactly the same while it builds and ten minutes after it stopped, which is the one thing somebody reading it from another tab wants to know -- and the only workaround available to a program was to write the answer into the words and keep rewriting them. So `busy`: printf '\033]5577;1;status;make test\033\\' printf '\033]5577;1;busy;1\033\\' ... printf '\033]5577;1;busy;0\033\\' A flag of its own rather than a field of `status`, because a status is the *whole* payload after the verb -- `status;a;b;c` has always been the text "a;b;c" -- so anything added to that line would change what every existing sender means, including the five pi extensions D1 promises byte compatibility to. Orthogonal, so a program can leave one line up and toggle the other; anything but 0/false/no/off is on; `clear` takes both; and a pane whose program has gone is never busy, whatever it last claimed, because a spinner turning under a dead tab is a lie you keep looking at. In a sidebar the row turns `tab_status_busy` -- the theme's accent, derived like the other status colours, so no theme needs a line -- and takes a spinner in the column `tab_bar_padding` was already holding blank, so nothing reflows when a program starts or stops working. `busy_mark` is the frames, separated by spaces: one frame is a static mark and costs nothing, several make it turn, and only then does the session keep a frame clock -- via the same `animating` flag an animated shader uses, so the cost lands only while such a row is actually composed, and `""` drops the mark entirely and leaves the colour to say it. The frame is a pure function of the clock, like every other pulse here, so two clients attached to one session show the same one. `panes` now reports `status` and `busy`, which is the scriptable half of the same fact: what a pane says about itself, without reading its screen.
* themes: a picker that wears them as you browse (C-a t)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `theme_name` is a line in a file, and a name in a list tells you nothing about a colour scheme. So the picker previews by *applying*: moving the selection puts that theme on, which is the only honest preview of a palette meant to dress a whole session -- a swatch in a list would be a small lie about it, and the session is already on screen behind the box. It opens on the theme being worn rather than at the top, so the first arrow press moves off it instead of silently changing the session, and the row your config names says so, because "which one is this" and "which one comes back on restart" are two different facts the moment you switch. Escape puts back what was worn, since a picker that changed your session by being looked at is one you stop opening. Enter keeps it for the session and says as much. Only C-s writes `theme_name` into your config, which is the one place in the UI where a keystroke edits it, so it is a deliberate chord and the footer says so: "C-s keeps it, esc undoes". Reuses the picker the finder, the palette and the projects list already wear -- one machine with a subject, so the query, the selection, the mouse, the frame and the scroll behaviour are the ones already tested. The list is scanned on the keystroke, like the projects are: the moment it opens is the moment it has to be right, and a theme written since the session started is exactly the one being looked for. With nothing to list it toasts the directory it looked in rather than opening an empty box. `t` was free: tabs cycle on tab, and new-tab has had `c` since the first day.
* docs: the tab bar's new shapes, written downuser2026-09-10
| | | | | | | | | | | | | A 'The tab bar' section on the panes page: the sidebar, its four knobs, the status rows under each tab, and the borrowed directory name for unnamed tabs. The config page's knob table learns the tab_bar_* keys. scripting.md and the driving-slosh skill stop underselling the status verb: it was documented back when the pane's own frame was the only place a status could appear, which made it decoration. With the sidebar it is how a pane reports progress to somebody working in another tab -- so say that, and say to keep it current and clear it when the work is done.
* graphics: a frame the client never got must be said againuser2026-08-29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane image is re-emitted to the client as decoded RGBA, which for one screenshot is ~4MB of base64 in a single indivisible message -- and conn_send silently dropped anything that would overflow the 4MB outbox, while push_graphics ignored the refusal and the model advanced anyway. Everything stateful in that dropped frame was lost for good: the image was marked sent (so its placements drew nothing, forever), and the deletions rode out with it (so a placement the client had was never revoked). Reported as screenshots in a recovered pi transcript parked at a fixed spot on the screen, immune to scrolling and pane swaps -- those were placements whose deletion left with a dropped frame. The 1000x800 one could never be delivered at all: its transmission alone was bigger than the cap. So the frame is now transactional. Deletions queue and are repeated every flush until gfx_commit() confirms the client heard them; transmissions recorded in a frame are unwound by an undelivered commit so they are sent again. A flush nobody commits counts as undelivered at the next gfx_begin(), which is what the `graphics` control command is: bytes rendered for a CLI, not the client. The server reports the verdict and keeps the paint pending until it lands. The outbox cap now bounds the backlog rather than backlog-plus-message, so one big transmission on a drained queue always fits -- and that surfaced a second way to lose: a queued image legitimately holds the backlog over the cap until the client swallows it, and push_frame read that as a dead client and closed the connection. The session auto-detached every time a screenshot crossed the wire. A full outbox is a client that is *behind*, not gone: push_frame now marks the screen for a full repaint (the dropped diff means the client's cells are unknown) and retries as POLLOUT drains the queue; a client that truly left still shows up as a write error there. The headless suite pins the owed-until-committed contract; the new tests/live_graphics.py drives the real socket with a bigger-than-the-cap image and a reader that stalls, and would have caught both bugs: the flood must not detach, the transmission must arrive whole, and a placement scrolled away must be deleted -- then come back on scroll without a second copy of the pixels.
* docs: less wordyuser2026-08-29
|
* 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: 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: 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: 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: 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: `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: 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: 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: 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 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: a README you can read, and docs/ for the restuser2026-08-15
The README was 331 lines and had become a manual with a title. Everything in it was true and most of it was the wrong thing to hand somebody who has just arrived: five pages of shader expression semantics before they have split a pane. So the front page is 60 lines -- what it is, install, run, where the docs are, and six bullets on what is actually different about it -- and the detail moved into docs/, nine pages that each answer one question: index.md what it is, install, run, where to go keys.md the leader, every default, rebinding, direct binds panes.md splitting, resizing, the mouse, dead panes, small terminals config.md the file, live reload, include, themes, the knobs shaders.md the passes, the built-ins, the expression language chrome.md where="chrome", channel, since, the perimeter trick layouts.md a session as a file, and writing one back out scripting.md the control socket, OSC 5577, headless design.md the two decisions, and a map of the source docs/nav holds the order and the grouping and nothing else -- titles come from each page's own heading, so a renamed page cannot end up with two names. It is what the site generator will read. Reference material that only existed in code comments is written down for the first time: every action that can be bound (including the two with no default key, which is what the palette is for), every control verb, the built-in shaders with what each takes, the pane-state ranking, and the layout file's shape. Two things I got wrong on the first pass and fixed before committing: the docs linked repo files through github.com URLs for a project whose remote is a private git server, so those are inline paths now; and the README advertised `make docs`, which does not exist yet. It will in the next commit, and it can be advertised then.