aboutsummaryrefslogtreecommitdiffstats
path: root/docs/config.md (follow)
Commit message (Collapse)AuthorAge
* 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: 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.
* themes: ask the binary where it is, not the prefix it was built foruser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `theme_name "sl0p"` with all seven themes sitting in /usr/share/slosh/themes: "no theme named sl0p". The lookup knew two places -- your own themes directory and the PREFIX this build was compiled with -- and on that machine the first did not exist and the second was /usr/local, because that is the Makefile's default and the package was built with PREFIX=/usr. Neither was where the themes actually were, and nothing in the search could ever have found them. A compiled-in prefix is a guess about where the binary will end up, and the binary is the one thing that knows where it really is. So the search now asks it: path_self() (/proc/self/exe, _NSGetExecutablePath, GetModuleFileName -- lifted out of server.c, which was already doing this for SLOSH_BIN) gives <bin>/../share/slosh/themes for an installed slosh wherever it was installed to, and <repo>/contrib/themes for one being run out of a build tree, which makes a checkout able to name a theme without installing anything first. The compiled-in prefix stays as the last candidate, for a binary sitting somewhere that says nothing about its data, and duplicates collapse so the usual install does not report the same directory twice. Your own directory is still first and still shadows everything, which is the property that matters; the rest is about the shipped set being findable at all. The tests that assumed a closed world of two temp themes now narrow to their own names by typing, since the shipped ones are (correctly) always in the list -- and the "no themes anywhere" toast lost its test, because with the shipped set reachable that state takes a binary with nothing beside it. The case that started this is a test of its own.
* 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.
* sidebar: air between tabs (tab_gap)Peter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | A tab carrying three rows of wrapped status and the tab under it carrying three more read as one column of text: the labels are in there, but the list has stopped looking like a list. tab_gap is the blank rows between them, 0 by default, which is exactly what the sidebar has always drawn. It is the space *between* tabs and nothing else. Above the first tab is tab_bar_pad's and before the button is newtab_pad's, so the gap is applied before each label except the first and none of the three is ever paid twice. Given up a row at a time when the list is short of room, ahead of every label and the button, which is the same order the pad and the statuses already follow: a gap that pushed a tab off the bottom would be spacing costing you the thing it spaces. Paint rather than layout, so tuning it repaints without reflowing any pane. Sidebars only -- the top strip is one row, and the space between tabs there is the label's own padding.
* sidebar: a status can have more than one row (tab_bar_status_lines)Peter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Sixteen cells is not enough for a sentence, and a sentence is what a pane has to say: "add a summary…" is most of a status and none of its meaning. tab_bar_status_lines 3 word-wraps instead of cutting, and the same column carries forty-eight cells rather than sixteen. Wrapped, the rows under a tab stop being a list of panes and become a paragraph each, so the shape no longer says where one pane's status ends and the next begins -- both are italic text down one column. Alternate panes are drawn on alternate bands to say it instead: tab_status_stripe, derived from the theme like tab_status_fg, so a theme that has never heard of any of this still gets a band in its own family. Nothing is painted at one line, where there is no ambiguity to resolve and a band would take a translucent terminal's background away for nothing. 1 is the default and is the old behaviour exactly, down to cutting at the column rather than the last word before it: with no next row to move the word to, breaking early would spend cells on nothing. The reason to prefer a word boundary only exists once there is somewhere for the word to go. Rows count against tab_bar_status as before, so wrapping trades panes for words within the same allowance, and the allowance itself still gives way to the tabs. A status that will not fit whole is dropped whole and reported with the ellipsis, rather than shown as a fragment that reads like the whole thing. Every row of a status is the same `find:` door. tabstatus's buffer goes to 256 to match pane_status's own slot: at one line the drawing cut it anyway, but wrapped it is the whole string that has to survive the trip.
* 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.
* sidebar: framed like a pane, and in compact mode framed *with* it ↵user2026-09-10
| | | | | | | | | | | | | | | | | | | | | | (tab_bar_chrome) A list of tabs sitting in bare columns beside a framed layout reads as text that wandered in from outside. So the sidebar gets a frame of its own -- and in compact mode not its own: its inner vertical lands on the tab area's ring column, and because the lines are strokes that union with whatever is already in the cell, the corners become tees without anyone computing a junction. The sidebar and the layout come out as one figure, which is the whole point. Classic mode gets a free-standing frame in the idle frame colour, rounded or square with everything else. The frame costs a column each side and a row top and bottom, taken from the sidebar's own budget -- and dropped entirely, before the list is, when the sidebar is too small to afford it: chrome must not displace what it is chrome for. The content box (cx/cw) now feeds the labels, the status rows, their hits and the truncation budget alike, so the frame cannot disagree with where the clicks land.
* docs: the tab bar's new shapes, written downuser2026-09-10
| | | | | | | | | | | | | A 'The tab bar' section on the panes page: the sidebar, its four knobs, the status rows under each tab, and the borrowed directory name for unnamed tabs. The config page's knob table learns the tab_bar_* keys. scripting.md and the driving-slosh skill stop underselling the status verb: it was documented back when the pane's own frame was the only place a status could appear, which made it decoration. With the sidebar it is how a pane reports progress to somebody working in another tab -- so say that, and say to keep it current and clear it when the work is done.
* 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.
* docs: big overhaulPeter Geissler2026-09-02
|
* docs: less wordyuser2026-08-29
|
* 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.
* README/docs: stop leaning on the aside dashuser2026-08-27
| | | | | | | | | | | | | Some 230 sentences hung their qualifications between dashes, and at that density the tic reads as the writing's signature rather than a tool it occasionally picks up. Each one is now the punctuation the sentence actually wanted: a colon where the aside defines, parentheses where it whispers, a comma where it barely pauses, a full stop where two thoughts were pretending to be one. Definition lists and reference tables take colons throughout. Two double hyphens remain on purpose, both literal: the argv separator in a shell example, and a table cell that means 'takes nothing'.
* README/docs: spell the dash --, as the rest of the tree doesuser2026-08-27
| | | | | | | | | | | Every C comment, commit message and generated config in this repo writes the aside dash as a spaced double hyphen; the Markdown alone used U+2014, some 220 of them. One spelling, and the ASCII one -- the prose now matches the code it documents. Mechanical (every occurrence was the spaced form, checked including the one inside a code fence, where it was comment text), so the words themselves are untouched.
* docs: config.md audited against the code, three correctionsuser2026-08-26
| | | | | | | | | | | | | | | | | | An audit of every claim in docs/config.md against the parser and the dump found the page complete -- all 47 top-level settings named, no ghosts, defaults and error strings as printed, 48 theme colours exactly -- and three statements wrong or imprecise, all fixed here and in the two comments that repeated one of them: - compact does not switch off gap_aspect. The divider ignores it, but padding is still aspect-corrected through it and the float nudge step still uses it; saying it 'stops applying' was wrong in config.md, config.h and config/config.kdl alike. - 'Two of those take more than one value' miscounted: padding, min_pane/min_split (cols=/rows= properties, whose syntax the page never showed) and project_roots all do. - a compact boundary saves four *columns* or three *rows*; 'four' with no axis was the columns number wearing a general claim.
* compact: shared borders instead of gaps (D23)user2026-08-25
| | | | | | | | | | | | | | | | | | | | | | One knob, `compact true`: panes pack flush against 1-cell divider lines that meet in real junctions, one frame rings the tab, and a pane's title rides the line above it -- a boundary costs one cell where the default look spends four. The lines are strokes: each write says which edges of its cell it needs, unions with what is there, and never clobbers text, so ├ ┬ ┼ fall out of paint order and the focus ring recolours without inventing arms. The divider *is* the gap (eff_gap, one answer for the layout, split_fits and the resize clamp), so gap hits, hover hints and corner drags work unchanged. Interior edges give up click-to-split -- one cell cannot hold two verbs -- and a pane's status row overlays its last content row. Floats, zoom and the D6 flatten keep classic frames through place_hidden: zero new tree cases. Underneath it, mode or no mode: a border drag now moves the boundary on that side, converted on the first motion so the press that never moves keeps its old meaning -- the handle's click still splits, the rim's is still deliberately nothing (drag.rim). Which is also the fix for gap 0, where flush panes leave no gap cell to grab and the resize gesture had silently vanished with it.
* docs: fact-check against the loaderuser2026-08-22
| | | | | | | | splash_ms and the project settings join the knobs table, the pane-state ranking gains the floating it was missing, contrib/themes' seventh file is named for what it is, the shader table's numbers are called examples with the real omitted-value defaults beside them, the index links workspaces, and the verb table learns splash.
* slosh: 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: 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: 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: layouts are `*.layout.kdl`, and each loader says which it gotuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `config/layout.example.kdl` becomes `config/example.layout.kdl`, and the convention for the files people write is `project.layout.kdl`. Not a new extension, and the reason matters: the syntax genuinely *is* the same hand-rolled KDL subset for both documents -- one parser reads them -- so calling layouts something other than KDL would trade one wrong signal for another and lose every editor that highlights it. The confusion is about *schema*, so the schema goes in the stem: the extension says how to read the file, `.layout` says what is in it. `docker-compose.yml` and `tsconfig.json` settled this convention long ago. **But a filename cannot enforce anything, and that turned out to be the real bug.** `sl0ppty --check` on a layout file printed `ok` and reported "bindings 53" -- it had read a document containing nothing it understood and said so was fine. A config that `include`d a layout linted clean for the same reason. So did `rouded true`. The loader reads a document by *asking it for the keys it knows*, which is fast and clean and never notices what it was given. So it notices now. `KNOWN_TOP` is every node the loader reads, and anything else at the top level is a complaint with a file and line: s.kdl:2: unknown setting: rouded example.layout.kdl:14: this is a layout, not a config: `sl0ppty --layout` reads those sl0ppty: config.kdl: this is a config, not a layout: `gap` is a setting The `layout` node gets its own message because it is the mistake people actually make, and the reverse direction is the same list read backwards -- the layout loader asks `config_is_setting()` rather than keeping a guess of its own, so the two answers cannot disagree about which file is which. A layout whose own tabs are misspelled still gets "layout declares no tabs", which is a different mistake. D2 records it, since it is the decision that two schemas share one syntax. Verified: `tests/test_config.py` gained thirteen checks -- the unknown setting with its line, both wrong-schema messages, the misspelled-tabs case keeping its own, the shipped config linting clean and the shipped layout loading as one, and a drift guard that greps `kdl_child(root, ...)` out of the loader and fails if a setting is missing from `KNOWN_TOP` (verified by removing `gap_aspect` and watching it fail). Full suite green; my own config and the reference config both still lint clean, which is the thing this change could most easily have broken.
* sl0ppty: docs that match the programuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Audited every page against the source and the binary rather than against my memory of it. Eight pages changed; the lists were all fine and the *numbers* were all wrong. Checked mechanically, and each of these came out complete: all 37 action names, all 28 control verbs, all 9 shader names, the expression language's 10 variables, 2 constants and 7 functions, the 8 pane states, every knob the dump prints, every OSC verb, every internal link and anchor, and every page's place in `docs/nav`. Every fenced `kdl` block was run through `--check`; every `{"cmd":...}` in an example is a verb that exists. What was stale: - **`make` does not build.** The default goal is `help`, so the install block -- `make vendor`, `make`, `make test` -- never produced a binary. It says `make all` now, timed at 5.5s from clean rather than "about a second". - **700-odd checks in about eight seconds** is now 1,500-odd in about thirteen, plus 260 from the C unit tests, which the sentence did not count at all. - **About 10k lines of C** is 15k. (10,799 of those are neither blank nor a comment, which is presumably where the old figure came from.) - **Seven chrome presets** are thirteen, and the six new ones use `sin` and `TAU` -- so the perimeter section gained the angle idiom it now depends on, and its "two things fall out of it" became three. - **`--check`'s example transcript showed two errors nobody can produce any more**: `bad prefix: C-a` and `bad key: ←`. Both inputs became *valid* -- `C-a` when the chord parser learned the notation the cheatsheet prints, and `←` when it learned the arrow glyphs, precisely so a chord copied off the screen parses. Replaced with a transcript I generated, and a note that the problems go to stderr, which matters for the pre-commit hook the page suggests. - **`bell_indicator`** was a real knob documented nowhere. - **"There is no key for this yet"** about moving a pane between tabs, two commits after the keys and the drag landed. - The cheatsheet's `+N more` line, the finder's `C-u`, and `tab_id` in a pane report were all real and unmentioned. Verified: the whole suite green, `make docs` builds nine pages, and the changed pages read correctly in a browser -- including the new transcript and the `>` `<` row of the key table.
* sl0ppty: a pane can set its own shadersuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | printf '\033]5577;1;shader;chrome;tint color="#ff5fd7" amount="abs(t / 8 % 510 - 255)"\033\\' The field after `shader` is which rect -- `content` or `chrome` -- and the rest of the payload is a chain in exactly the syntax a `shaders { }` block uses, taken verbatim so that `;` can go on separating entries. Empty text clears that chain. The session answers on the program's stdin, `shader-reply;ok` or `shader-reply;error;bad amount for tint: ...`, because a prototyping loop that gets no answer cannot tell a typo from a build without the feature. `contrib/shader-repl` is the prompt this exists for: type a chain, watch the pane, and `:paste` prints what you have as a config block. Both ends are the same parser, so what you arrive at is what you can keep -- verified by pasting `:paste`'s output into a file, linting it, and checking the frame comes out the same colour. **D13 said this was impossible on purpose, and the reasoning stands**: a program that can restyle the session it happens to be running in is a hazard, and `cat hostile.txt` should not dim your panes. But *impossible* was the wrong shape for it -- editing a file, saving, looking and guessing again is slow enough that shaders go unwritten. So it takes `in_band_shaders true`, one line of consent, and it is scoped to the pane that asked: its own two chains, never its neighbour's, never what the config said about anyone else. There is a test for the neighbour. Each chain owns the programs its expressions compiled to, and is swapped in whole, so a chain that turns out not to read leaves the pane looking like it did rather than half-restyled. Measured: 1,800 replacements of an animated chain, resident size flat after the first pass. Also here, because it is the same paint order: the chrome chain now sits between the config's and policy's, exactly where the content chain does. Dead code out -- `app_shade_add`/`app_shade_clear`/`app_shade_count` had no callers and their comment claimed the opposite of what this commit does.
* sl0ppty: padding, per sideuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | padding 1 // every side padding 0 2 // vertical, horizontal padding 2 0 0 1 // top, right, bottom, left One value, two, or four in CSS order, because that is the order anybody who has written a stylesheet already knows. `pad` becomes four fields and the content rect is deflated per side rather than symmetrically, which is the only real change: everything downstream already worked off `n->content`. The units do not change with how many numbers you write. They are ROWS, like `gap`, and a horizontal one is multiplied by `gap_aspect` on the way out -- so `padding 1`, `padding 1 1` and `padding 1 1 1 1` are one padding rather than three, and a ring that looks square is still one number rather than an arithmetic problem. Anyone who wants plain cells sets `gap_aspect 1` and gets them everywhere, which is what that knob is for. Three values is refused with a line saying what it wanted. CSS reads it as top/horizontal/bottom, and a line whose meaning you have to look up is a line nobody can read -- and `sl0ppty --check` now has something to say about it, which is the difference between refusing and ignoring. The dump writes the shortest form that means what is in force, so `padding 0` stays `padding 0` and a dump of a dump is still the same dump. Checked by measuring both rects: a pane's insets from its own frame, four numbers, for every form -- including that `gap_aspect 1` turns the horizontal ones into cells, and that a refused padding leaves the rest of the file applied.
* sl0ppty: sl0ppty --checkuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A config linter, on the CLI: $ sl0ppty --check # what a session would read $ sl0ppty --check themes/mine.kdl # or a file you have not installed config.kdl:101: bad prefix: ctrl+nosuchkey themes/mine.kdl:7: unknown shader: bloom ~/.config/sl0ppty/config.kdl: 2 problems $ echo $? 1 A mode rather than a script, because the only checker that cannot drift from what a session does is the loader a session uses. The single difference is how many problems each reports: a session has one status line and shows the first, and a linter that stopped at the first mistake would make you run it once per mistake. Exit 1 when it has anything to say, so it drops into an editor's compile step or a pre-commit hook with no glue. A clean run prints what it read -- the files, including everything `include`d, the prefix it ended up with, how many bindings -- because a config that was never opened and a config with nothing wrong otherwise look identical, and "it printed nothing" is not evidence. An optional path so it can lint a theme before you install it, and $SL0PPTY_CONFIG is honoured when there is none, so it checks the same file the session will. tests/test_check.py covers the contract: exit status both ways, every problem reported rather than the first, file and line on each, an included file naming itself, a file that will not parse saying nothing applied, a missing file being a problem rather than a crash, and the one that would have caught the bug in the commit before this -- a config written by `--dump-config` lints clean.
* sl0ppty: a README you can read, and docs/ for the restuser2026-08-15
The README was 331 lines and had become a manual with a title. Everything in it was true and most of it was the wrong thing to hand somebody who has just arrived: five pages of shader expression semantics before they have split a pane. So the front page is 60 lines -- what it is, install, run, where the docs are, and six bullets on what is actually different about it -- and the detail moved into docs/, nine pages that each answer one question: index.md what it is, install, run, where to go keys.md the leader, every default, rebinding, direct binds panes.md splitting, resizing, the mouse, dead panes, small terminals config.md the file, live reload, include, themes, the knobs shaders.md the passes, the built-ins, the expression language chrome.md where="chrome", channel, since, the perimeter trick layouts.md a session as a file, and writing one back out scripting.md the control socket, OSC 5577, headless design.md the two decisions, and a map of the source docs/nav holds the order and the grouping and nothing else -- titles come from each page's own heading, so a renamed page cannot end up with two names. It is what the site generator will read. Reference material that only existed in code comments is written down for the first time: every action that can be bound (including the two with no default key, which is what the palette is for), every control verb, the built-in shaders with what each takes, the pane-state ranking, and the layout file's shape. Two things I got wrong on the first pass and fixed before committing: the docs linked repo files through github.com URLs for a project whose remote is a private git server, so those are inline paths now; and the README advertised `make docs`, which does not exist yet. It will in the next commit, and it can be advertised then.