aboutsummaryrefslogtreecommitdiffstats
path: root/config (follow)
Commit message (Collapse)AuthorAge
* 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.
* sidebar: room for the spinner, by default (tab_bar_padding 0 1 0 2)user2026-09-22
| | | | | | | | | | | | | | | | | | | `│⠋bash: cd` -- the mark landed in the one column of left padding there was, so it ended up welded to the first letter of the status. The rule was "the mark takes a column the padding was already holding blank", which is right about not reflowing and wrong about there being enough of it. So the mark takes the *first* column of the indent and whatever is left over is the air after it, and the default indent is two columns instead of one. One column is still available -- `tab_bar_padding 0 1` -- and now means the squashed look on purpose rather than by default; three gives two spaces; zero still puts everything against the frame. Nothing reflows when a program starts or stops working, which was the point of taking a column that was already blank. The labels and the tab numbers move with it, because the indent is the strip's and not the status's -- a status still starts in the column its tab's name starts in, which is the rule the leading space existed for in the first place.
* 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.
* sidebar: air inside it, the way panes have padding (tab_bar_padding)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `padding` is a pane's: the ring between a frame and the text it holds, in rows times gap_aspect so a `1` looks square around a block of prose. Handing the same knob to the sidebar looked tempting and is wrong twice over. The sidebar is chrome, not a pane, and its width is fixed by tab_bar_width -- so `padding 1` would quietly eat four of a sixteen-column strip (two cells a side, aspect included) and shorten every tab name, because somebody wanted their terminal text to breathe. And its two axes are not one idea: the columns are an indent for labels, the rows are air above a list. So tab_bar_padding, its own knob in the family the rest of the sidebar's air already lives in (tab_bar_pad, tab_gap, newtab_pad), taking the same 1/2/4 CSS-order values as `padding` -- one reader for both, so they cannot drift into meaning different things -- but counted in cells. The default `0 1` is what the sidebar already drew: that one column is where every label's leading space has been hard-coded since the sidebar existed, and moving it into a setting is what makes `tab_bar_padding 0` possible at all -- names flush against the frame, which was not previously a thing anyone could ask for. The statuses, the `+ new tab` row and the pane count follow the same indent, since they were each carrying their own copy of that space. It is air *inside* the plate: a tab's fill still spans the whole row and the whole row is still the hit, because the row is what you click and a highlight that stopped short of the frame would read as a highlighted word rather than a chosen row. The bottom rows are given up when the strip is short of them, like every other piece of air in here. tab_bar_pad is the older name for the top component alone. It still works, still means exactly that, and wins where a config writes both -- it is the more specific of the two. The dump writes tab_bar_padding and mentions it.
* sidebar: align the numbers by defaultuser2026-09-22
| | | | | | | | | | | | | | | | | | | The knob landed defaulting to "prefix" on the grounds that it is what the strip has always drawn -- but the strip is not what this is about. A sidebar row is wider than the name on it, and `1:api` spends the first two or three cells of every row (three or four once you have ten tabs) on a number nobody reads until they reach for C-a 2, pushing the names in by a distance that depends on how many tabs are open. Right-aligned, the names start where the statuses under them start and the numbers make a column of their own. `tab_bar_index "prefix"` is still there and unchanged, and the top strip is unaffected either way -- it ignores the setting, having no far end to align against. The tests that used `1:` as the anchor for "where a tab's name starts" now anchor on the name, which is what they were about; two new ones cover the default and the prefix form explicitly, so neither mode is tested only by being the default.
* sidebar: the tab number at the far edge (tab_bar_index)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `1:api` puts a number at the start of every row, and in a sidebar that is a column of digits in front of the thing you are actually reading -- the names start one, two or three cells in depending on how many tabs you have, and the numbers are only wanted at the moment you reach for C-a 2. tab_bar_index "right" moves them: │ tests 1 │ │ 1:tests │ │ verylongproj 2 │ vs │ 2:verylongproje│ │ api 3 │ │ 3:api │ Every name starts in the same column and the numbers line up in one of their own, which costs a column of sidebar and buys the list a shape. "prefix" stays the default, because it is what the strip has always drawn and the strip is where most people meet it. Sidebars only, and not by omission: a tab in the top strip is exactly as wide as its label, so there is no far end to align against and a number floating after a name it does not belong to would be worse than the prefix it replaced. The setting is ignored there rather than half-honoured. A row too narrow to spare the columns (under the number plus five) also keeps the prefix, since the name is the part you came for, and the rename editor keeps the whole cell as it always did. A name long enough to reach the number stops one cell short of it, because `verylongproje2` is a tab called verylongproje2 as far as any reader can tell. The cell comes back as padding, so the plate is unbroken and the hit is still the whole row -- clicking the number is clicking the tab, and the number brightens with the label under the pointer rather than leaving half a row lit. The bell mark keeps its place between them, budgeted first as before, so a long name cannot eat either of the two things that are not the name.
* theme: a name and a directory, not sixty colours in your configuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Wearing a theme meant pasting its `theme { }` block into config.kdl, or including a path to a file you had copied there yourself. Both make the palette a literal in your config, and a literal is the one thing you cannot change cheaply: not from a script, not from a running session, not by a list of what is available, and never again from upstream -- the colours you pasted are the colours that theme had the day you pasted them. So a theme is a file in a directory now, and a config names it: theme_name "phosphor" theme { frame_focus "#00ff88" } // ...but that one colour is mine theme_name is applied after the includes and before everything the file says itself, which is the rule includes already follow and for the same reason: the loader reads a document by asking it for names rather than walking it in order, so "here" is not a position it could honour, and "what you asked for is the base, what you wrote is yours" is the rule worth having. Two directories, yours first: `themes/` beside the root config -- beside the file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed to --check) takes its themes with it -- and then <prefix>/share/slosh/themes, compiled in from PREFIX. install(1) has been putting themes there all along and nothing could read them; now a shipped theme is nameable without being copied, and a file of yours with the same name shadows it, which is how a root-owned theme gets edited. A name, not a path. The directory is what makes a theme switchable, listable and shadowable, and a path silently does none of those, so `theme_name "../x.kdl"` is refused with a line pointing at `include`, which still works and is still the answer for a file somewhere a directory of names cannot reach. A theme that is not there is one complaint and no more (D9): the rest of the config applies, because losing your keybindings over a mistyped colour scheme would be the wrong trade. A theme file is read by the same loader, so it is watched like any other: saving one reloads the session wearing it, and the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours exactly as before. Switching without editing anything, over the socket a session already has: slosh -s main cmd theme # amber default* ... slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' The session holds the name over later reloads, so saving your config for an unrelated reason does not put the old colours back; `save` writes the theme_name line into the file, replacing the one already there and leaving every other line and comment alone -- dumping the whole config back over itself would reformat a hand-written file and drop its comments, which is not a fair price for changing a colour scheme. A name is tried on a throwaway config first, so a typo costs nothing. An empty name gives the answer back to the file. --dump-config stops writing colours: it writes the theme's name and the colours a config overrode by hand, so a dumped config keeps following its theme instead of freezing it. --dump-theme is the other half and is new: the resolved palette as a theme file, which is how you start one from the session in front of you rather than from an empty file. It is also what keeps contrib/themes/default.kdl honest -- that file is now the annotated reference (the commented palette moved there out of config.kdl, where it was 119 lines of colour in the middle of the prose), and a test compares it against the compiled-in one. While in there: tests/test_themes.py's "every theme sets every colour" guard had been grepping for `&c->`, the spelling before the colour table moved to offsetof, so it had been finding zero colours and passing for months. Fixed, with the derived and opt-in colours exempted for the reasons they are derived and opt-in, and with a check that the guard found a table at all.
* tabs: idle tabs you can actually read across the roomuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | Seen next to the accent, the first plate was too polite to do the job it was added for: a 30% lift off the background under labels in the same #45454a the idle frames use. From another monitor the strip still read as one dark band with a blue block in it. Both ends move. tab_idle leaves the ambient chrome grey and becomes #8a8a95, because a tab you are not in is a place to go and has to be legible at a glance, where a dimmed pane border only has to be findable. The plate follows it to 43%, which is not a taste constant but a contrast ratio: the same step taken from a dark background upward and a light one downward lands at about 1.9:1 either way, so paper's #bdbebe on near-white and the default's #3b3b40 on black are the same amount of plate. The six themes in contrib named their idle tab colour as their frame grey, so each gets the same move in its own family -- 40% of the way from that grey to the colour the theme writes text in -- and their plates follow by derivation: amber #a67c26, mono #909090, paper #666a6e, phosphor #4a9c5d, slate #70789c, sl0p the house #8a8a95. Nobody who named a colour loses it; the derivation only ever fills in a theme's silence.
* tabs: a plate under the ones you are not in (tab_idle_bg)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | The active tab has been filled with the accent since the strip existed; every other tab was words on the background. That leaves the strip with one shape on it and nothing to compare it against -- where a tab ends, how far the row you are about to click reaches, and in the sidebar which rows are tabs rather than statuses, all had to be read off the text. tab_idle_bg fills them. Barely off the background and in the label's own grey, because this is not a second highlight: it gives a tab edges, and it gives the accent something to be brighter than, which on a strip of short labels is most of how the eye finds the tab it is in. Hover keeps it for the same reason the active tab keeps its fill -- losing the plate under the pointer would read as the tab going away -- so hovering still lands on the text, and the bell mark sits on the plate rather than punching a hole in it. Derived a third of the way from default_bg to tab_idle when nobody names it, the same bargain tab_status_fg and tab_status_stripe already make: both ends are colours every theme in contrib defines, so each gets a plate in its own family -- paper lightens, phosphor greens, mono stays grey -- without a line added to any of them. Naming it wins, and naming your own background is how you ask for the bare tabs back. The `+ new tab` button and the gaps between tabs stay unpainted: the button is the one row in the list that does not mean "go here", and tab_gap is air between plates, which is what makes them read as separate tabs at all.
* tabs: air before the new-tab button, and the option to do without itPeter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | | | The `+` is the one entry in the strip that does not mean "go here", and it sits at the end of a list where every other entry does. Flush against them it is what you hit when you meant the last tab -- most easily in a sidebar, where the target is the full width of the row and the pointer only has to be one row low. newtab_pad is the nothing in between: blank rows in a sidebar, blank columns on top, one by default, which is enough to make the slip a miss. It is paint rather than layout, like tab_bar_pad: the panes' rectangle is the same at 0 as at 5, so tuning it repaints without reflowing anyone. And it is given up before the button is -- a strip short of room drops the spacing one cell at a time and still draws what the spacing was protecting, because spacing must never cost you the thing it spaces. newtab_button false removes the button outright, for people who make tabs from the keyboard and would rather have the row (or the columns) back. Only the place to click it goes: the chord and the palette entry still work. Setting newtab_mark "" already did this by omission; this says it on purpose, and says it in --dump-config.
* search: a scrollback search bar on /, matches highlighteduser2026-09-08
| | | | | | | | | | | | | | | | | | | | | | | | C-a / opens an inline search bar on the focused pane's status row, on lib-vt's GhosttySearch (D4's pattern: the scrollback walk, reflow and alt-screen survival are the library's, the UI and the wiring are ours). Type to search live; the viewport follows the current match; up/down (or C-n/C-p) step through them with wraparound; Enter keeps your place, Escape snaps back. A k-of-n counter reads on the right, or "no matches". Every match on screen is tinted, and the one the bar is on is tinted apart -- warm gold by default, painted after the shader pass so the scrolled wash does not dilute a match you are hunting for. Four themeable colours (search_fg/bg, search_cur_fg/bg). Bare / took the key from help, which keeps ?; that needed the legacy decoder to read the shift out of a punctuation byte the way it already did for capitals -- without it ? and / decode identically (SLASH, no mods) and cannot be two keys on a client without the kitty protocol. tests/test_search.py drives it end to end (39 checks): the bar, the counter, stepping and wrap, all-match vs current highlighting, Enter/ Escape, new output updating the count, case-insensitivity, the keyboard belonging to the bar, click-away, and that / and ? no longer collide.
* attach tag: just the count, in theme coloursuser2026-09-02
| | | | | | | The +x+y offset goes -- 'N clients' says the thing that matters. The inverse-video chip goes with it: the tag now draws in attach_fg / attach_bg from the theme, defaulting to gray ink on the terminal's own background. Standing information, not an announcement.
* multi-attach: say who else is watching, and where you areuser2026-09-02
| | | | | | | | | | | | A small tag in each client's own top-right corner when several terminals share the session: the viewer count, and on a view that is a crop being panned by somebody else's cursor, the crop's offset into the shared screen ("2 clients +12+4") -- which is exactly the moment a screen moving on its own needs explaining. Stamped per client after projection, because every terminal has its own count of others and its own offset; the shared status bar is composed once and can be cropped out of a panned view anyway. attach_indicator false hides it.
* multi-attach: the config knobsuser2026-09-02
| | | | | | | | | | | | | The patch made shared attach unconditional; these make it a policy. multi_attach false restores the classic displacing attach (EXIT_REPLACED, which old clients already understand). size_follows picks whose size the shared screen takes: active (the patch's rule), smallest (the tmux rule: filler, never a crop), or largest. The size fold lives in apply_canvas, so any attach, resize or departure can move the canvas under the folding policies -- including a non-active client leaving, which under smallest may have been the very client holding the floor down. A config reload re-applies both knobs to the running session.
* shaders: remove the .so plugin loaderuser2026-09-02
| | | | | | | | | | | | The static musl release builds cannot dlopen -- musl compiles it out -- so loadable shader plugins were a feature most shipped binaries could not use. Nothing we ship used it either: every shader in contrib/shaders is an expression. Gone: the dlopen/glob loader in shader.c, shader_dir in the config, the plugin ABI table and entry macro, the Windows dl*/glob shims that existed only for it, the contrib/shader-plugin skeleton, its tests, and every doc mention. shader_abi.h stays as the shader model (cell_t, shade_ctx_t, shader_t) with a note about what it used to be.
* ctrl_d_exits: Ctrl-D at a prompt, where the tty cannot do itPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Windows has no VEOF. The console driver does not know the character, so ^D arrives at cmd.exe and PowerShell as an ordinary keystroke and they ignore it -- it even echoes, which is how you find out: the line reads `echo hello^D`. The one key that closes a pane on every other platform does nothing at all, and closing a pane becomes a word you have to type. That you can type `exit` instead is the tell: the intent was never ambiguous, only unspoken. So slosh speaks it. What goes to the pane is the word, not a signal and not a close from outside -- the shell runs its own cleanup, returns its own status, and keep_dead decides what is left behind, all three of which reaching past it would skip. It is also what the user would have typed, so there is nothing new to explain. The knob is what makes it honest, because a wrong guess costs a pane. It fires only with the shell itself in the foreground (a program that is running may want the EOF for itself -- `cat` reading to end is the whole job), only on the primary screen (in an editor or a pager ^D is half a page down, and the alternate screen is the pane stating that rather than us matching process names), and only on an empty line (POSIX sends EOF there; what it does not do is discard what you typed). The line is an estimate. The shell's editor is on the far side of a pty, so pane_send_key counts what it forwarded since the last Enter. Editing it does not model -- a recalled history entry, ^U, completion -- leaves the count too high, which forgets to exit. That is the direction to be wrong in; the other one throws away a half-typed command. Default true on Windows, false on POSIX, where the line discipline already does this and does it properly: ^D there is also EOF for `cat` and every REPL, none of which is ours to intercept. A knob rather than an #ifdef so the guards are the same code everywhere and can be tested where the test suite runs -- test_ctrl_d.py turns it on and drives all four decisions, plus the case that does exit. Telling trapped from forwarded needs a program that reports what it received: on POSIX a forwarded ^D also ends an idle shell, and looking for the word `exit` cannot separate them either, because bash prints `exit` itself on EOF. Verified on the Windows box as well, where it is the default: ^D at an idle cmd.exe prompt takes the pane (and with it the session, having no panes left), `more` in the foreground keeps its own EOF, and a half-typed line survives with the line still on screen.
* selection: drag past an edge scrolls the viewport under ituser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Text taller than the viewport was not selectable in one gesture: the drag stopped dead at the pane's border. Two faults, one visible and one waiting. The visible one: DRAG_SELECT motion subtracted the content origin in uint16_t, so a pointer above or left of the pane underflowed, the grid lookup failed, and the selection froze. The waiting one: the anchor was a pair of *viewport* coordinates re-resolved on every extend, so the moment anything scrolled mid-drag the anchor would have drifted with the viewport, re-anchoring to whatever slid under the press. The anchor is now a tracked grid ref -- the thing lib-vt documents as being for 'long-lived anchors such as selections' -- so it follows its cell across scrolling, pruning and reflow, and only the near end of the selection moves. If a program flooding output prunes the anchored row out of scrollback entirely, the selection keeps what it had; there is no cell left to anchor a new answer to. Motion clamps into the content rect (a pointer past an edge is pointing at the nearest cell), and holding past the top or bottom edge scrolls on a clock rather than on motion, because a resting pointer sends no events and 'keep going' is exactly what resting it there means. app_tick() does the step, app_next_deadline_ms() wakes the poll loop for it -- the same mechanism toasts and hover guides already use -- and both front ends call the tick, so the behaviour exists under the server and the headless harness alike. Rows per step is how far past the edge the pointer sits: one row past creeps, further hurries, so the hand already dragging is also the throttle. The metronome is select_scroll_ms, a knob so the test can configure the clock down instead of sleeping. The test drags to one row above the pane and simply holds: the viewport walks to the top of scrollback (clamping makes the endpoint exact), the copy starts at a line that was never on screen with the press, and still ends at the pressed row -- that last check is the tracked anchor's.
* 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.
* slosh: bell_mark defaults to "[!]"user2026-08-21
| | | | | | Plain ASCII that says what it means, over a dot that had to be noticed. The mark was always a short string rather than a glyph (the emoji-width argument in config.kdl stands); the default just stops being shy.
* slosh: the scrolled edge fade earns its nameuser2026-08-20
| | | | | | | | Four rows deep at 60 a row instead of three at 45: the edge row is nearly gone (#0e0e0e from white) and the melt reads as "there is more this way" rather than as a rendering artefact. The quarter-viewport cap, the (above > 0) gate and everything else about the derivation stand; a config that writes its own scrolled chain is untouched, as ever.
* 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: greet an attach with the logo, wearing a random shaderuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | logo.txt is the source of truth; the Makefile bakes it into build/logo.h the way version.h is baked. When a client attaches (MSG_HELLO turning a connection into the display), the logo appears centered over everything for splash_ms (900, 0 for never), on a cleared backdrop, in the accent -- and one of six effects runs over its rect through the ordinary shader pass: a diagonal sheen, plasma, rings, sparkle, a left-to-right reveal, a breathe. Which one is picked by the splash's own timestamp, so every attach looks a little different without anybody keeping a counter. Every effect reads the clock, so the frame clock runs only while the splash is up (note_animation, same as any animated chain), and `since` in a splash context is milliseconds since it appeared, so one-shot effects sweep exactly once. Any key or click ends it and still lands where it was aimed: a greeting is not a modal. A screen the box does not fit on gets no greeting rather than a cropped one. Headless and scripted sessions never attach, so the whole fast suite never sees it; {"cmd":"splash"} triggers it on demand, which is what makes it testable from a scripted session -- and lets you replay the greeting whenever you like.
* slosh: defaults most people can live in, said by shadersuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
* slosh: rename the project from sl0pptyuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | sl0p + pty was error-prone to type and the zero made it worse; slosh (slop + sh) is one syllable, impossible to typo, and slosh.foo is ours. Mechanical rename everywhere it appears: binary, env vars (SLOSH, SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-<uid>, $XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards, src/sl0ppty.h -> src/slosh.h, contrib/sl0ppty-dev -> contrib/slosh-dev, the driving-slosh skill, docs and tests. The sl0ppi fork references and the sl0p theme stay: one is history, the other is the brand. The rename surfaced a latent bug in test_osc5577: the hello-reply answer always appeared on screen twice (ECHOCTL echo plus cat -v), but a seven-character implementation name happened to wrap the second copy across the 60-column boundary, so counting substrings in wrapped text found one and passed for the wrong reason. A five-character name moved the wrap and exposed it. The loop guard itself is correct -- with echo off before the request there is exactly one reply -- so the test now counts on de-wrapped text and tolerates the pane's own echo.
* sl0ppty: the 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: double-click a word to select ituser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pane_select_word()` was written and never wired to anything: no declaration, no caller, no way to reach it. This is the rest of it -- the gesture, the setting, and the tests. **The word is defined by what it stops at, not by what it is made of.** Anything that is not blank and not a listed separator belongs to a word, so CJK, accented letters and emoji need no table and come out whole; whitespace always separates, listed or not, because a double-click that selected the line would be a different gesture arrived at by surprise. Wide glyphs continue the run they are in rather than each being their own word -- otherwise every CJK character is a word. `word_separators` defaults to `"'`()[]{}<>;,|`, and what it leaves *out* is the decision: `/ . - _ : = @ # % ? & + ~ $` stay inside words, so a path, a dotted name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are each one thing you double-clicked to copy whole. `:` is the difference between copying an error location and copying half of one, which is why kitty keeps it too. The gesture reuses what selecting already has: the press starts a selection at the cell -- which anchors a drag out of the double-click -- then widens it to the word, and the release path that already copies a drag copies this. So the word *is* the copy, in the one place that rule lives, and the highlight is the copied span rather than an approximation of it. Tracked per cell as well as per pane, so clicking two different words quickly selects the second rather than everything between them. A program that asked for the mouse still keeps its clicks, with shift as the escape hatch, exactly as the drag has it. **One thing this needed on the way: quoted values were dumped unescaped.** The default separator set opens with a quote, and `cb_add(&b, "key \"%s\"\n", ...)` wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking the property the dump exists for. The escaping loop already existed inside `cb_chord`, for the same reason and with the reasoning written down; it is now `cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`, `editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those, load-bearing for this one. **And `--dump-config` does not do what four comments and the docs said it did.** It calls `config_dump_defaults()`: every setting with its *default*, never the value your config gives it. The function name was the honest one. Corrected in `docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's own docstring, rather than changing what the flag does -- starting a config from it is the documented use, and `--check` is what reads yours. There is no way to ask for the effective config; that is a gap, not a lie, and it can be a flag when somebody wants it. Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight being exactly as wide as the copy; a separator, a blank cell and a single click each leaving the clipboard alone; two clicks on different cells not being a double; two slow clicks not being one; the separator set honoured from a config, including a quote and a backslash carried through KDL escaping; a word in a scrolled pane coming from the row on screen; and a mouse-tracking program keeping its double-clicks until shift takes them back. Three of those failed first because the test asked for the answer twice -- a second `worded()` call is a second pair of clicks -- and one because it mapped string indices to columns without counting wide glyphs as two.
* sl0ppty: workspaces, and layouts that write themselvesuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A **project** is a directory on disk. A **workspace** is the tab it occupies in a session. Two words because they are two things, and conflating them is how a session grows a second source of truth about what it is looking at. `project_roots "~/dev" "~/work" depth=2` is scanned for subdirectories holding a `sl0ppty.layout.kdl` -- or a `.git`, which is the project that has not said what it needs yet. Both markers, because a `~/dev` of forty checkouts and three layout files is a picker with three rows, and `C-a W` is how a project stops being the second kind. A directory that *is* a project is never descended into, so the walk stays off `node_modules` without anyone writing a rule about `node_modules`. `C-a w` is the third subject of the one picker (D19): the finder searches what this session has, this lists what is on disk, including the projects with no workspace open yet, which is most of them and the whole point. **Identity is the tab's `purpose`** -- `project:<name>.<hash8>` of the resolved path, the shape D3 already published -- so there is no second field to disagree with it, membership is a string comparison, and a dumped session restores it for free because a dump already writes tab purposes and applying one already locks them (D8). Hashed on the *path* rather than the name, so two worktrees of one repo are two workspaces, which is what they are. A tab whose layout gave it a purpose of its own keeps it and is simply not a member: overwriting a declared purpose is the one thing D8 forbids, and the reply counts the ones it left alone. **Nothing watches the filesystem.** The list is derived when it is asked for -- one `readdir` per root and two `stat`s per entry -- and an answer nobody remembered cannot be stale, which is a promise no watcher can make on a bind mount. The picker scans once as it opens, not per frame, because `draw_picker` asks for its rows every frame and a `readdir` per repaint is a filesystem walk at 120Hz. Opening is idempotent (`sl0ppi up`'s property): the same request twice is one workspace, focused, and the answer says which branch it took. Four things had to be true before `save-workspace` could exist, and none of them were. **A relative `cwd=` in a layout file now means that file's own directory** -- the rule `include` already followed for configs, applied to the other half of the same syntax. A project's layout could previously only name absolute directories, which are right on exactly one machine and wrong in a worktree of the same repo. **A dump can be asked for one tab, against a base.** `dump-layout` takes `tab`, `relative_to` and `suspend`. Writing it also turned up a bug: `dump_node` asked `cur(a)->focus` whichever tab it was writing, so a dump of three tabs restored the focus of one. **A dump records what a pane is *running*.** `label` only ever knew what a layout said, so a pane you split and typed `npm run dev` into had nothing to say for itself and came back as a bare shell -- which made arranging a project and writing it down two jobs instead of one. The kernel knows what the label does not: a pty has a foreground process group, and that group is by definition the job that owns the terminal, so the answer is read the way `live_cwd` already reads `/proc/PID/cwd`. Three things are deliberately not commands: a shell at a prompt (its group is the pane's own, and `command="zsh"` on every idle pane restores a session where each shell runs inside a shell), a background job (it does not own the terminal), and an ephemeral pane. A declared label still outranks a live process, because it survives the program exiting (D14) and re-saving must not degrade `npm run dev` into whatever the process tree looks like this minute. argv is joined with shell quoting, since `command=` goes through `/bin/sh -c`. **A pane from a split starts where the pane it came out of stands**, not where the server was launched. Wrong on its own, and it had been quietly writing an absolute path to somebody else's directory into every saved project. A saved project defaults to `suspend="commands"`: the pane running this morning's dev server is written asleep, because a *project's* layout is a statement of shape and a *session's* dump is a statement of fact. Over the API, replacing a layout that is already there is refused without `force`; from the keyboard `C-a W` replaces and says `replaced`, because a key cannot pass a flag and the file is committed text whose diff is the review. `--check` now holds a layout to the layout schema instead of answering with the name of the flag that reads one. `unknown pane property: cmd`, `split is cols or rows`, `purpose is ignored on a pane with panes in it` -- the mistakes a layout can make that still parses. The lists live beside `build_pane` and a test greps for every prop the loader reads, the arrangement that keeps `KNOWN_TOP` honest. **`C-a P` sets a pane's purpose**, a third subject on the editor the pane title and the tab label already share. A purpose is what tooling finds a pane by, and it could only be declared by a layout or set over the socket -- so every pane anybody arranged by hand had none, and a layout dumped from one was a shape with no tags in it. Typed by an operator it counts as declared (D8: "a layout or an operator"). Clearing one now *unlocks* it: a lock held over an empty string was the one state nothing could escape. `set-purpose` also reads `id: 0` as the focused pane now, which every sibling verb already did. Two gaps found on the way, both reachable only over the socket before: **`C-a X` closes a tab.** It used to be a side effect -- press `x` once per pane and the tab evaporates when the last one goes -- so four panes meant four presses and counting. It says how many panes went, since most of them were not the one you were looking at, and on the last tab it refuses and points at `q`: a key that closes a tab four times and ends your session the fifth is one you cannot press without counting first. No `✕` on the tabs, for the arithmetic that took the per-pane `+` away. **`scrollback` is a number we choose.** D4's "free from lib-vt" was true of the machinery and quietly false of the size: handed no limit, lib-vt picks its own -- 10,000 *bytes*, which measured at 622 lines of an 80-column pane, less than one `make` run. `scrollback` is lines, defaulting to the 10,000 every other multiplexer settled on, and `scrollback_bytes` is the ceiling it runs into, because either limit can be reached first and neither can see what the other depends on. Measured at 10,000 lines of styled output: ~6 MB at 80 columns (the ceiling never bites), ~15 MB at 200 (2% of the lines), ~32 MB at 400 (half of them). No `unlimited`, deliberately: the ceiling is per pane, and a negative is refused rather than wrapped into eighteen quintillion lines. Read when a pane is created, so it applies to the next pane you open -- same rule as `shell`, because shrinking a pane's history retroactively throws away output somebody is reading. `--project-root` was considered and dropped: the config is the one place projects are declared, and tests pin a config like every other config-driven feature. Verified: 1752 checks, all green. 90 in `tests/test_workspaces.py` (the two markers, depth without descending into a project, two worktrees as two workspaces, idempotent open, the honoured-purpose count, the save/close/open round trip byte-for-byte, the set-up-by-hand-and-write-it-down flow, the picker, and every verb's bare form); 53 in `tests/test_portable_layouts.py` (relative and absolute `cwd=`, text having no base, per-tab focus, the four `suspend` words, captured commands, an idle shell and a background job captured as nothing, spaces surviving the round trip, label precedence, and the checker's lists not going stale); 21 in `tests/test_purpose_editor.py`; 14 in `tests/test_scrollback.py`; 5 more in `tests/test_tabs.py`. `tests/test_osc5577.py` had a vacuous check: `\033` through the driver's unescaper is NUL + "33", so "a pane cannot relabel a declared purpose" passed without the sequence ever arriving. It uses `emit_cmd` now and has a positive control that the same sequence lands once the purpose is cleared. Three cheatsheet tests were pinned to terminal sizes that exactly fitted the old sheet, which four new bindings pushed past. They now ask for room; truncation has its own test.
* sl0ppty: `C-a Enter` splits the longer wayuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | One key for "I want a pane and I have no opinion about where". It cuts across the pane's longer side, so the two halves come out as square as the pane allows, and pressing it four times on a 100x30 terminal tiles the tab the way you would have done it by hand: a column, then rows inside the new column, then rows again, then a word saying there is no room left. "Longer" is a question about what you can *see*. A cell is about twice as tall as it is wide, so 80x24 is a wide pane (80 against 48) while 40x24 is not (40 against 48). `gap_aspect` is already this program's answer to how many columns a row is worth, so that is the number used rather than a second constant meaning the same thing -- and a config that says cells are square gets a square answer. There is a test at 74x44, the size where the two readings disagree: square cells split it into columns, the default splits it into rows. The fallback is the other half of "sensible". A pane that is wide but ten rows tall wants cutting across; if `split_fits` says that will not fit, the other axis is tried before giving up. And it says which way it went -- `split right` or `split down`, the same two words the border click already uses -- because one key that can do two things has to tell you which it did. **`split_fits` had a hole this action made obvious.** It only ever asked about the dimension being divided, so a pane four rows tall on a short terminal "had room" for two columns: the width was fine, and the two panes it produced were immediately collapsed into a list by the layout. The border was offering that, and both split keys were doing it. It now also requires the axis it does *not* divide to clear the floor, because splitting cannot improve that one -- the same hole `layout_node` closed at the leaf, one level up. On a 100x8 terminal all three now say "no room to split" instead of undoing the arrangement. Verified: nine checks in `tests/test_split_ux.py` -- the axis chosen for wide and for tall panes, the aspect deciding at 74x44, both fallback directions, the refusal and that the explicit keys refuse the same way, and that it appears on the cheatsheet under `enter` and in the palette by name. The existing 65 checks about what the border offers still pass, which is what I was most worried about.
* sl0ppty: layouts are `*.layout.kdl`, and each loader says which it gotuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
* sl0ppty: `C-a >` sends this pane to the next tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C-a > push it to the tab after C-a b into a tab of its own C-a < push it to the tab before Three actions rather than three special cases in the key handler, which is what puts them in the cheatsheet and the palette without another line of code -- and the palette is where you find a thing you did not know to look for. You do not follow the pane, and a toast says where it went: "moved to tab 2 (api)". A key you might press twice needs to say what it did, and following would take you away from what you were doing. The tab's *name* is read before the move and its *number* after: emptying this tab removes it and shifts everything along, so the number read first is not the number anybody will see. When the tab you were in is the one that goes, you arrive in the survivor because there is nowhere to stay. `>` and `<` are the shifted period and comma, and the defaults bind both halves -- a terminal does not report shift for punctuation, so `>` arrives as a bare period the way `?` arrives as a bare slash. The config loader already did this for a chord you write by hand; the defaults had to do it themselves. The cost is that `.` and `,` are spoken for, which is the cost `/` already pays for `?`. `b` is for break out, the word tmux taught everybody for this. **Two things the tests caught, both mine.** `panes` reported a pane's tab as an index while every command that takes a tab wants an id -- and my own test conflated them, passing only because the two agree until a tab is removed, which is half of what these keys do. There is now a `tab_id` beside the `tab`: one is where it sits in the strip, the other is what you pass back. And three more rows overflowed the cheatsheet: `modal_frame` clamps the box to the screen, so at 56x44 the `session` group was simply not drawn. The sheet now says `+7 more, in docs/keys.md` when it cannot show everything -- counted by walking the rows it skipped, because arithmetic on two uneven columns claimed six were hidden on a screen showing all of them. A list that quietly stops is worse than a short one: the reader cannot tell that the key they wanted was on it. Reflowing to two columns when the height forces it would be better than a footnote, and is not this commit. Verified: `tests/test_move_pane.py` at 38 checks -- the keys in both directions, breaking out, the refusal when there is only one tab, pushing the last pane out and landing in the survivor, and that the sheet prints `>` rather than the bare period it also answers to while the palette finds it by label. `tests/test_help.py` gained the short-terminal case and lost an assumption about its own fixture height.
* sl0ppty: 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 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: every complaint, and the chords that produced eleven of themuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Reported as "C-a is not a valid prefix in my config.kdl", which it was not, and neither were ten other lines of the same file -- a file written by `sl0ppty --dump-config`. The loader now collects every complaint instead of keeping the first. Each one carries the file and the line it happened at, so "line 14: bad binding" became "config.kdl:116: bad key: ←", and a complaint from an included file names that file. `err` still means the first message, so a session's one status line behaves as before; the list is what a linter needs, and `--check` (next commit) is that linter. Both changes live in the same regions of config.c, which is why they are one commit. Then what the list found. `--dump-config` is documented as "a file you could have written", and `--dump-config > ~/.config/sl0ppty/config.kdl` as a supported way to start one. It wrote every chord in the *cheatsheet's* notation: prefix "C-a" bind "?" "help" bind "←" "focus-left" bind "S-tab" "prev-tab" and the parser only spoke the config's, so eleven of the file's own lines came back as complaints and the session ran on the defaults those lines were describing. Two sessions looked identical for the worst possible reason, which is why the test asserting exactly that never noticed. Both halves were wrong, so both are fixed. The parser accepts what the sheet prints: `C-` `M-` `S-` as well as `ctrl+ alt+ shift+`, the arrow glyphs as well as `left`/`right`, and a character that carries its own shift -- `?` is shift+slash, `H` is shift+h, `|` is shift+backslash. config.kdl has claimed for months that `?` and `S-slash` were both writable; only one of them was. A punctuation binding registers *both* encodings, because whether a terminal reports `?` as shift+slash or as a bare byte is up to the terminal and not to what the config meant. The shipped default has bound `?` twice by hand for exactly this reason since it was written; this is that, done once, in the one place that knows the character implied the shift. A capital letter needs no such thing -- the decoder reports `H` as shift+h everywhere. And the dumper writes the canonical config spelling: `ctrl+a`, `shift+slash`, `left`, `shift+tab`. `shift+` spelled out rather than folded back into `?` or `H`, because both are accepted on the way in and one spelling on the way out keeps a dump of a dump the same dump. A key with no spelling the parser would accept is written as an empty chord rather than as its display form: writing the display form is what caused this, and a dump that does not load is worse than a dump missing a line. The test that should have caught it now does: a session started from the dump is asked to reload and must have nothing to say. Looking at the screen was never going to find this, because the screen was right.
* sl0ppty: marks a font actually draws at cell sizeuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ▬ □ ✕ away, bigger, gone ▬ ■ ✕ ...and while it is already filling the tab The boxed operators of the commit before this (⊞ ⊟ ⊡ ⊠) were one tidy family and unreadable with it. They are *math* symbols, sized to sit beside x and y in an equation, so at a terminal's cell size the interior that carried the whole meaning -- the minus, the plus, the dot -- was two or three pixels of it. Four identical smudges. Geometric shapes and dingbats are drawn at full cell size, which is what makes them readable at all, so legibility wins over uniformity here: four different shapes, one verb each. Picking glyphs that fill their cell is also the answer to the evenness that put plain ASCII here originally -- a glyph that does not fill its cell hands the slack to the gap beside it, by an amount the font decides. A bar for minimise rather than the obvious ▼, because the split guide already owns all four triangles (◄ ► ▲ ▼) and the scroll indicator owns ▲ again. A glyph that means "split downward" on the border of a frame cannot also mean "put this pane away" in its corner. Found by the guide's own test failing on the first attempt, which is exactly what that test is for -- and the reason it caught a UI collision rather than a typo is that it asserts only *one* arrow is on screen at a time. Widths checked in a real frame again: the rule still closes on its corner, each button is still measured rather than booked, and the hits land on the glyphs.
* sl0ppty: one family of marks for the frame's buttonsuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ⊟ ⊞ ⊠ away, bigger, gone ⊟ ⊡ ⊠ ...and while it is already filling the tab `_ # x` said what they did only if you already knew, and `*` for a zoomed pane was the weakest of the four: nothing about an asterisk says "put it back". So there are four marks now instead of three and a half, with the unzoom state getting one of its own. ASCII was there for a reason, and the reason survives: the cells are even, but a glyph that does not fill its own cell hands the slack to the gap beside it, and how much it hands over depends on the font -- which is why box-drawing and block characters were tried here and looked unevenly spaced. Four copies of *the same box* answer that better than four unrelated glyphs did: whatever a font does with the box, it does four times, and only the interior changes. Minus for away, plus for bigger, dot for back to normal, times for gone. Single width, checked in a real frame rather than assumed -- the row still closes on its corner and each button is still measured rather than booked, so the hits land where the glyphs are. Not emoji, which many terminals draw two columns wide while chrome here is booked as one. Two tests were pinning the old glyphs by hand (`line.rindex("x")` for the cheatsheet's close button, `"*" in line` for the zoom state). They read them out of `sl0ppty --dump-config` now, which is generated from the code: picking a nicer mark later cannot leave a test asserting a glyph nothing draws.
* sl0ppty: say what the config could not honouruser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A config that half-applied said nothing. An unknown shader, a binding that does not parse, an include that is not there: the file loaded, that line did not, and the session announced "config reloaded" as though nothing had happened. Every one of those messages was already being produced -- config_load has filled `err` on a successful load since it was written -- and then dropped on the floor by every caller, because a non-zero return meant "fine". So the complaint from the last load is kept, and the front end says it where it would have said "config reloaded". Once at startup too: a config that loaded with a complaint still started the session, and the stderr line goes where nobody attached can read it. The `reload` control verb answers `{"ok":true,"warning":"..."}` -- ok, because the file did apply, with the reason attached for a script that cares. Two things fell out of writing the test for it. An include's complaint was reported by nobody when the file in the middle was fine. apply_includes only looked at the return value, so a theme that loaded while *its* include was missing swallowed the message: the child returned true, and true meant there was nothing to say. The complaint now travels up whether or not the include itself loaded. And a complaint now carries the name of the file it happened in, exactly once -- added by the file that produced it, never by the files that included it, because prefixing at every level builds a chain that truncates before the part that matters. So: t.kdl: unknown shader: bloom keys.kdl: line 2: unknown action fly b.kdl: includes nested too deep (a cycle?) cannot open /home/you/.config/sl0ppty/themes/nope.kdl which is enough to know which file to open, in a message that fits in a toast.
* sl0ppty: a config can be made of filesuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | include "themes/amber.kdl" include "keys/vim.kdl" "shaders/crt.kdl" which is how you switch a theme by editing one line rather than pasting one in. Nothing about it is theme-specific: an include is another config file applied here, so the same line composes keys, shader chains, states or geometry, and contrib/themes stops being a directory you copy out of. A relative path is relative to *the file doing the including*, never to the directory the session was started from -- a config is a thing on disk that refers to its neighbours, and where you were standing is not part of what it means. A leading `~` is a home directory, an absolute path is itself, and an included file may include others. What you include is the base: the file with the `include` line in it wins wherever they disagree, and a later include wins over an earlier one. That holds wherever the line sits, because the loader reads a document by asking it for the keys it knows rather than walking it top to bottom -- so "here" is not a position it could honour, and "the file you are editing wins" is the rule worth having anyway. It is what makes a theme something you can put two lines on top of. A depth limit rather than a set of files already seen, because the two failures it has to stop are the same failure: a config that includes itself and a config eleven files deep are both a mistake, and the message says which one you have. A file that will not load is skipped and the rest of the config still applies (D9) -- losing your keybindings over a mistyped theme name would be the worse answer. The loader was already shaped for this: `config_load` applied one parsed root onto a config_t, so it becomes a recursive `load_into` that applies its includes first and then itself. Errors from an include get their own buffer, so a broken theme cannot overwrite a message the file that included it has already produced. tests/test_include.py covers the two rules that carry the weight -- the outer file winning either way round, and a nested include resolving against the file that wrote it -- plus multiple files on one line, `~`, a binding from an included file, a missing file, a cycle, switching theme through a reload, and every file in contrib/themes applying as an include, checked against each file's own frame_focus so it cannot pass by looking default. Not yet: an included file is not watched, so editing the theme itself does not reload the session. Editing the include line does.
* sl0ppty: shaders on the chrome, and two keys for the arrangementuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Five things, in one commit because they land in the same few files -- the action table, the shader pass, config.kdl -- and splitting them by hunk would buy a tidier log at the price of commits that do not build. Each is verified end to end; the suite is 46 files green. Chrome shaders. `where="chrome"` on any entry in `shaders` or in a `states` block runs that pass over a pane's frame -- border, padding, title, buttons -- instead of its contents. Nothing else changes: same registry, same `amount`, same expressions, same chain order, one new word, because a frame is another rect and so it is another pass. It runs over the frame's whole rect with the contents punched out as a hole, rather than four passes over four sides: an effect has to be able to travel round a border, and four passes would each count x/y from zero and restart at every corner. The hole keeps the two honest -- a skipped cell is never visited, so a full-strength chrome tint cannot reach your text. It is also the only kind of pass that can colour a pane a small window has collapsed to a single row (D6), which is the state where a colour is worth the most. `channel="fg"|"bg"|"both"` came out of looking at the first border flash: `tint` mixes both of a cell's colours, and a frame's background is the terminal's own default, so the pass materialised it to our guess at that default and mixed towards the colour -- a dark rectangle behind the glyphs, left there until the pane changed state. The mask is enforced by the pass, not the shader: the other colour is snapshotted before materialising and put back after, so an unset colour goes back to unset, it works for a built-in and a plugin alike, and no shader grows an fg-only twin of itself. A `bell` pane state and a `since` variable, because "flash the border when a pane rings" needed both and neither existed. `since` is milliseconds since the pane entered its current state, stamped in the one function that decides the state -- a transition is the one thing a frame cannot tell you, so it is remembered where it cannot disagree with the state it dates. `bell` ranks above the ambient states and below `dead`: a pane that rang while you were elsewhere is news, and a pane that rang and then exited is a pane that exited. It ends when you look at the pane, because that is what answering a bell is. Animation needed a clock and did not have one: a shader reading `t` only advanced when something else caused a frame, so a pulse froze the moment you stopped typing. `app_next_deadline_ms` now reports `anim_ms` while a chain that reads the clock ran over the last frame -- derived from what was applied, so a pulse hung off `dead` costs a clock while a pane is dead and nothing for the rest of the session. A `deadline` verb makes that observable; the server's poll loop already lived on the number. contrib/chrome has seven presets and the perimeter trick they are built on: on a frame the coordinate you want is distance along the ring, which the language can work out, and `min(d, P - d)` is what makes an effect cross the corner it started from. Hand-written, unlike contrib/shaders, because the toy previews contents and a frame has no preview to give -- so tests/test_chrome_presets.py checks each one the only way left: load it into a real session, assert the frame moves and the contents do not. `equalize` (C-a =) puts every share back to even: each split's children weighted by how many visible panes are behind them, so one pane beside a stack of three is a quarter of the width and "even" means the same thing at every depth. Exactly equal cell counts are not something a tree can always express, so the action promises the even division it can. `rotate-layout` (C-a Space) turns the whole tab a quarter turn clockwise. Every split changes axis and a row split's children reverse, because the pane on top of a stack is the pane on the right once you turn it; get that asymmetry wrong and you have a mirror image, which looks almost right until four turns fail to come back. They do come back, exactly, which is why the inverse is three more turns rather than a second mirrored routine with its own chance of being wrong -- and why a key as easy as space is the right one rather than a hazard. A turn is refused when it would not fit: a share of the height is not always a share the width can afford, and a node that cannot give its children their floor takes the whole tab down to a list. So the turn is tried, the layout is asked what it made of it, and one that flattened the tab is put back with a word. Same rule `split_fits` applies to splitting. Keys: the palette moves from `shift+p` to `p`, and cycling tabs from `n`/`p` to tab/shift+tab. `p` is pressed far more often than "the tab before this one" and was the only shifted letter in the defaults; tab is what every other tabbed thing cycles with, and `\e[Z` decodes as shift+tab on a plain terminal, so this costs nothing on a client without the kitty protocol. `n` is free again. The actions are untouched: two lines of config put the old binds back. The shader ABI goes to 3: `shade_ctx_t` gained `state_ms` and struct shader gained `channels`, both the pass's business rather than a shader's. Plugins need a rebuild, which is what the version is for.
* sl0ppty: a command palette, on a key you chooseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every action by name, without having to know its key. The cheatsheet answers "what is the key for this?" and then needs you to press it; this answers "just do the thing", which is the question you have when you cannot remember there was a key at all. `C-a shift+p` by default, and `bind "<key>" "palette"` for anything else. It lists actions that are bound to nothing, which the cheatsheet by design cannot -- that sheet is built from the binds, so an action with no key does not appear on it, and that is exactly the action you have no other way to reach. Two of ours are in that state today (scroll by a line, either direction). Where a binding does exist the palette shows it beside the row, so using the palette teaches the key that would have skipped it. Where several exist it shows them all: a config's binds add to the defaults rather than replacing them, so naming only the first would hide the one the user actually chose. Rows match on the phrase, the group, and the name a config file uses, so "split into columns", "panes" and "split-cols" all find the same thing depending on whether you have been editing the config lately. The finder and the palette are now one picker with a subject rather than two machines that would drift -- the same modal, query, filtered list, selection, keys and mouse rules, differing only in what fills the rows and what Enter does with one. Choosing goes through the row's own action string, so the keyboard and the mouse cannot take different paths to the same row. The finder's 55 checks pass unchanged across the refactor. Left out on purpose: `literal-prefix`, which means "type this key" rather than "do this thing" and picked from a list would send the prefix to a pane hidden behind that list; and `select-tab-N`, which is positional, so a row reading "go to that tab" cannot say which. Running is by action rather than by synthesising the keystroke that would have caused it -- a palette that had to invent input to do its job would be lying to the rest of the program about what happened. That meant splitting prefix_command in two, which it wanted anyway: the lookup and the doing were only ever together by accident.
* sl0ppty: make the finder a modal, and a picker worth usinguser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | It was a bare coloured rectangle painted over whatever pane it landed on: no border, no title, no scrim, no way out you could see. It read as output from the program underneath rather than as a window in front of it, which is the one thing an overlay has to get right. It is now a modal like the cheatsheet -- same frame, same scrim, same close button in the place a pane keeps one -- differing in the way that matters: the cheatsheet is read, so any key dismisses it, while this one is used, so it keeps the keyboard until it is answered. The list was the other half of the problem. It showed `1:` for a tab with no name, which looks like something failed to load; a row for a pane with no purpose was three columns of nothing; there was no mark for the pane you are already in, no count, and nothing to say a list had more below the tenth row -- so a list that scrolled looked like a list that ended. An empty result was an empty box, which reads as a broken finder rather than a wrong query. Now: a query field that looks like one (so it shows where the typing goes before you type), the current pane marked, tab/title/purpose told apart by weight, the row under the pointer lit, "N of M" in the footer, arrows for what is off each end, and "no pane matches that" when there is nothing. Keys, because a picker in this shape is reached for by hands that already know one: C-n/C-p, tab/shift-tab, home/end, page up/down, C-u to clear the query, and wrapping at both ends -- one press of Up for the last entry is worth more in a short list than overshoot protection. Backspace deleted a *byte*, so one press through a multi-byte character left the rest of it in the query; it deletes a character now, the way the rename editor already did. The finder owns the pointer as well as the keyboard: a press that is not on one of its rows dismisses it and stops there, rather than also landing on the layout behind and focusing the pane you were clicking away from. Found on the way: CSI Z (shift+tab) was not parsed at all, so it reached neither a binding nor the program in the pane -- every terminal sends back-tab that way, and it is the one chord whose shift lives in the final byte rather than in a modifier param.
* sl0ppty: watch the config directory even when it is not there yetuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | You were right. The watch is on the *directory* -- editors write a new file and rename it over the old one, so watching the file stops working the first time you save -- but a directory that does not exist cannot be watched at all. inotify_add_watch failed, the session gave up on watching for good, and writing a config later did nothing until a restart. Which is exactly the state a fresh machine is in, and exactly the state C-a e leaves you in. sl0ppty creates its own config directory at startup now, so there is always something to watch and a file appearing later is picked up. Tested from a home directory with no .config at all. Answering the other half of the question -- which settings actually reload -- turned up one that never worked at all: `shell`. main.c read $SHELL directly and nobody read the config's value, so the setting parsed, rendered in --dump-config, and did nothing. It is resolved per pane now, which fixes it and makes it live: edit it, save, and the next pane you open uses it. Everything else measured rather than assumed, and it is nearly everything: colours, geometry, the status bar and line, frame marks, shader chains and their states, and the keys including the leader itself. The two exceptions are about *when* a value is read, not reluctance -- `shell` at pane creation, and shader plugins which load on reload but never unload. Both are now written down where someone would look. One test needed rescuing from itself: it asserted the new leader worked by splitting, in panes that were by then too narrow to split. A test that fails on geometry while claiming to be about the prefix is worse than no test; it opens a tab instead.
* sl0ppty: render the config instead of shipping a copy of ituser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `C-a e` on a machine with no config opened an empty buffer, which tells you nothing about what you can set. The obvious fix -- embed config/config.kdl and write it out -- trades one problem for a worse one: a checked-in copy of the defaults is a second source of truth, and it drifts. Ours already had. Four theme colours existed in the code and not in that file, and nothing said so. So the file is generated. `sl0ppty --dump-config` renders every setting with the value it currently holds -- geometry, behaviour, all 48 theme colours, the state shader chains, and every binding including the leader-less ones -- and `C-a e` seeds a missing config with exactly that, creating the directories on the way (a fresh container has no ~/.config either). config/config.kdl stays, and is now explicitly the *prose*: what a setting is for, what it cost to get right, which ones are opinions. Values are generated, essays are written, and the file says so at the top. Three things keep the two honest: - the theme colours became one file-scope table that the parser and the renderer both walk. 48 names that used to exist in one place and be typed again in another now exist once. - the dump must parse back to itself byte-for-byte. That is what caught `bind \ split-cols` rendering unquoted -- valid-looking output that would not have survived being read back. - a test scans config.c for `kdl_child(root, "...")`, which is the parser's own vocabulary, and fails if a key is missing from the dump. Add a knob, forget to render it, and the suite tells you rather than a user. It never overwrites a config that exists: fopen(..., "wx") loses the race rather than an edit, and there is a test for that too, because it is the one thing here that must never go wrong.
* sl0ppty: C-a e opens the config, in a pane, in your editoruser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | The config watcher's whole point is the loop -- edit, save, watch the session change under you, edit again -- and the loop had a hole in the middle of it: you had to leave the session, or remember where the file lives, to start. So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than across because a config file is lines and half the height beats half the width for reading them. `editor` in the config beats $EDITOR, and vi is the fallback, being the one editor a POSIX system must have -- a wrong guess you can see and change beats a pane that opens empty. The interesting part is what kind of pane it is. Panes given a command keep their corpse (D14), which is right for a dev server and wrong for this: quit the editor and you would be left dismissing a dead pane you opened thirty seconds ago. Rather than gaming that policy by giving the pane no command -- which would be a lie in the one field dump-layout and re-run read -- panes can now be *ephemeral*: a task rather than part of the session's shape. An ephemeral pane keeps no corpse and is not written into a dumped layout, so a restart does not reopen somebody's editor on a file they had finished with. That concept is the reusable half of this commit; the keybinding is the part you notice.
* sl0ppty: only panes that were given a command keep their corpseuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Keeping every dead pane was too much. Split off a shell, do something in it, type `exit` -- and you are left with a corpse to dismiss, which is fussiness rather than information. You know what happened. You did it. So the policy has a line now, and the line is the *command*, not who made the pane. A pane told to run `npm run dev` is part of the session's shape: when it dies you want the error it left and the button that runs it again. A pane that is a shell is finished when you say so. Not provenance, because a shell is a shell whether a layout file made it or C-a \ did -- and that also means a session restored from dump-layout behaves like the one it was dumped from, since the dump writes `command=` only for panes that had one. The distinguishing mark turned out to need no new state: pane_label() already holds the command a layout gave it and is empty for a plain shell, so this is a policy over something already recorded. `keep_dead` is now `commands` (default), `all` or `none`, and still reads `true`/`false` as `all`/`none` -- that was the shipped spelling and a config saying it should keep working rather than quietly changing behaviour. Most of the diff is tests. The dead-pane suite had been written against the session's own pane, which is a shell under the new rule, so it was testing the half of the policy that now says "reap it". They run commanded panes from layouts instead, and the pair of cases -- a shell you split off goes, a commanded pane stays -- sits together in one place so the rule is legible in the test file too. One of those layouts wanted an escape sequence, which has to be written `\\033` so KDL's unescaping hands `\033` to the shell. Written singly the shell prints `033[38;2;...` and the test reads as "the colour stopped working".