summaryrefslogtreecommitdiffstats
path: root/src/app.c (follow)
Commit message (Collapse)AuthorAge
* 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.
* app: cut the modals, toasts and splash out of the images under themIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | Overlays are painted over the panes and the compositor needs no more than that: painted later is on top. A placement is not painted, though -- the client's terminal draws the image over the cells, in whatever order they arrived -- so an overlay that is only drawn is an overlay an image sits on top of. The cheatsheet came up under a picture and was unreadable through it, in the terminal and in the mirror both. So a frame now records where its overlays landed, and the graphics pass takes them out of a placement exactly as it takes out a float: the modal's box (the cheatsheet and the picker wear the same frame), each toast, and the splash. The list is rebuilt every compose, so a rect that is not registered again is one that is no longer on screen. Only the opaque things are registered. The scrim behind a modal tints what is behind it rather than hiding it, and an image cannot be tinted by us at all -- so a picture beside the cheatsheet stays bright while the text around it is pushed back, which is wrong-looking but a great deal better than the image disappearing while a modal is up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
* 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.
* Add competitive multiplayer support.Ingmar Steen2026-09-02
| | | | Last input wins, canvas size is adjusted to active connection.
* 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.
* windows: a native port, on ConPTY and AF_UNIXPeter Geissler2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | slosh builds and runs on Windows 10/11, x86-64 and ARM64, as one exe with no runtime dependency beyond the OS: panes, splits, tabs, detachable sessions, the config file and its live reload, the control socket, the mouse. The port is a compatibility layer rather than a fork. compat/win holds stub <poll.h>, <sys/socket.h>, <termios.h> and friends that redirect to include/compat_win.h, which is force-included ahead of every translation unit -- so the existing sources keep their POSIX includes and their POSIX call sites, and the platform differences are answered in one place. Two decisions carry most of it. Everything the event loop waits on is a socket. On POSIX a pty master, an inotify descriptor and a unix socket are all file descriptors, so one poll() covers them; Windows has no such union, and WSAPoll waits on SOCKETs and nothing else. Rather than rewrite server.c's loop around WaitForMultipleObjects -- which caps at 64 objects and cannot express POLLOUT -- each non-socket source gets a pump thread that copies it into one end of a loopback socketpair. The loop is then the POSIX one unchanged, and only what sits behind the descriptor differs. Socket descriptors are tagged rather than guessed: a wrapped SOCKET comes back as SL_SOCK_BASE + slot, so read, write, close and poll can tell a socket from a CRT descriptor with certainty instead of inferring it from the numeric range. That is why socket() and accept() are shimmed too -- an untagged descriptor escaping into the loop is the one failure mode this scheme has, and it is worth closing completely. Three things bit hard enough to be worth naming. A pseudoconsole does not stop the child inheriting your stdio. CreateProcess copies the parent's standard handles into the child when STARTF_USESTDHANDLES is absent, and those win. With slosh's own stdout redirected -- a pipe under ssh, a file under --script, a log under the daemon -- the shell wrote its banner into slosh's output instead of into the pane, and the pane had a correct title (ConPTY emits that itself) over an empty body. The three handles are cleared for the duration of the call, leaving the child nothing to inherit. A ConPTY never reports EOF. The output pipe belongs to the pseudoconsole, not to the child, so it stays open after the child exits and pane_pump()'s read() -- the entire mechanism by which a pane learns its program is gone -- never returns 0. A pane whose shell had been told `exit` sat there looking alive forever. So the exit is waited for on the process handle, and ClosePseudoConsole is what then produces the EOF. That third thread is also why the per-pty state is reference counted: a pump blocked on a dead pipe must not be waited for, or pty_close would hang the session on the one program that misbehaves, so whoever finishes last frees. cmd.exe does not use CommandLineToArgvW's quoting. A literal quote is \" to an ordinary program and a backslash-then-end-of-quoted-section to cmd, so building `cmd /c notepad "C:\...\config.kdl"` the first way handed the editor \"C:\...\config.kdl\", backslashes and all, and notepad answered "Not a valid file name" about a path that existed. cmd /c now gets the rest of the line verbatim -- the caller composed a complete command with its own quoting, exactly as it does for sh -c. Paths are mixed, and only some programs mind. slosh joins with '/' while the environment supplies '\', so the config landed at C:\Users\you/.config/slosh/config.kdl. The CRT opens that without complaint, which is why --check reported it fine, but a GUI file dialog rejects it outright. Anything handed to another program now goes through sl_path_native() first; the internal representation is left alone. The rest is small. Panes run %ComSpec%, sessions live in %LOCALAPPDATA%\slosh rather than $XDG_RUNTIME_DIR because a session socket must not follow a roaming profile to another host, there is no SIGWINCH so a resize is noticed by comparing the console size on a bounded poll, no fork so the daemon is a fresh detached process that reconstructs its arguments on the command line, and no process group on a pty so the foreground program is the youngest descendant of the pane's shell. C-a e prefers whichever console editor is actually installed -- notepad is a GUI program, so a pane running it draws nothing and looks broken -- and says so when it has to fall back. make -f Makefile.windows ARCH=x86_64 (or aarch64) cross-compiles from a mac or Linux, and builds natively on the box with zig on PATH.
* 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.
* macos: the kernel still knows, it just spells /proc differentlyuser2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Portability fixes contributed by our macOS developer, applied with one hardening tweak (below). Darwin has no /proc, so everything that read one gets the native answer: live_cwd via proc_pidinfo(PROC_PIDVNODEPATHINFO), the foreground command via sysctl(KERN_PROCARGS2) parsed back into the NUL-separated shape /proc/<pid>/cmdline gives, and SLOSH_BIN via _NSGetExecutablePath plus realpath. The pty gets its winsize set on the slave after opening it, because Darwin attaches no line discipline to the master until then and the earlier TIOCSWINSZ was silently dropped -- a pane born 0x0. The shader repl binds tab in libedit's own words, since macOS ships Python's readline as a libedit wrapper that accepts GNU syntax in silence. Two finds are cross-platform bugs: a toast wider than the screen minus the gap computed a negative x that uint16_t made ~65533, drawing the longest messages -- the ones most worth reading -- off screen; now the middle is elided (a path's tail is the half you needed) and x clamps. And a pane label was 128 bytes, which a Homebrew interpreter's full path exceeds before its arguments even start; dump-layout wrote the truncation down and a restore ran it, so it is 1024 now. Saving a layout learned cwd_as_named: the kernel answers with the physical path, and on macOS /tmp is a symlink to /private/tmp, so a layout that said cwd="/tmp" stopped round-tripping through a save. If realpath(named) is where the pane still is, the named spelling wins. Tests follow: realpath'd tmpdirs, the scroll test reads the shell's first prompt instead of spelling out bash 5.3, and wrapped pane text is joined before matching a long path. One tweak on top of the contribution: the elision needs five columns for a head, the dots and a tail -- below that `avail - 3` goes negative, and negative as size_t is a pointer into the weeds; a terminal that narrow gets a plain clip.
* 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: put-away panes are miniature pane framesuser2026-08-21
| | | | | | | | | | | | Brackets were tried and still read as text. Each min-bar entry is now a three-row chip wearing a real pane frame -- the same corners, because a frame is what a pane *looks like* in this program, so a shrunken one reads as exactly what it is: a pane, clickable. The put-away mark rides inside each chip and the lone legend is gone; a rung pane's bell mark keeps its own colour inside the frame; hover lights the whole chip and the whole chip is the hit. The bar costs three rows instead of one while anything is minimised (and nothing when nothing is), which is what an affordance that works is worth.
* slosh: minimized panes wear buttons in the stripuser2026-08-21
| | | | | | | | | | | A row of dim bare names read as a caption, and nobody clicks a caption. Each entry now wears square brackets -- [▬ name] -- because that is what clickable already looks like in this program: [re-run], [close], the buttons a pane's own row offers. The put-away mark rides inside each entry instead of standing at the head of the row as a legend: a legend explains the row once, a mark per entry says what each button is. A rung pane's bell mark keeps its own colour inside the brackets, and the whole bracket is the hit, as one geometry demands.
* slosh: 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: the logo flies into place -- a particle engine, statelesslyuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every non-space glyph of the logo is a particle that flies to its cell over the first two fifths of splash_ms, then *is* the logo for the rest. There is deliberately no particle state anywhere: a position is a pure function of (target, elapsed, seed), recomputed every frame like the layout and the pane states and for the same reason -- physics that is stored can drift, physics that is derived cannot. The seed is the splash timestamp, so every attach flies differently and one splash is coherent frame to frame; a per-particle hash staggers departures, and the delay rescaling lands every straggler exactly at the end, because an almost-assembled logo reads as a bug. Five motions, picked by the clock and pinned by {"cmd":"splash", "motion":N}: rain (fall in, column by column), scatter (converge from everywhere), slide (rows enter from alternating sides), vortex (spiral in around the centre, orbit shrinking and unwinding), shuffle (the glyphs trade places, then sort themselves). The colour chains keep playing over the box throughout, so particles catch their hue as they cross into it. expr_sin() is the expression language's own sine exported for C-side animation: a motion sketched in the repl and one compiled into the binary cannot disagree about what sin(90) is. The tests learned two things the flights taught: the logo's opening run appears three times per line and assembly is staggered, so a middle occurrence can land first -- "assembled" is the row whose first block glyph is where the match begins -- and windows need margins that survive a parallel suite on a busy box.
* slosh: the splash learns colouruser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | One tint is one colour, and every effect wore the theme accent -- six effects that were all the same dull blue. An effect is a *chain* now: phase-shifted tints in loud literal colours, mixing per cell, which is how a fixed-colour pass machinery does rainbows. Passes may light the backdrop's background too, so the box is a scene rather than tinted text, and the logo is drawn white -- the canvas that takes every hue at full saturation, where tinting blue towards pink lands on mud. The six: spectrum (rainbow bands), plasma (two interference patterns in clashing hues), fire (heat up the glyphs, embers in the box), rings (rainbow ripples), glitch (a reveal under two colours of static), aurora (green and violet curtains over cyan). The splash is deliberately the one place that does not defer to the theme: it is branding wearing a demo, not chrome. {"cmd":"splash","fx":N} picks an effect by index -- a test needs a deterministic one, and "show me the aurora again" deserved an answer. The new test counts dominant-channel hue families on the logo rows, because two hundred shades of the same blue is exactly the failure it guards against.
* 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: split app.c into five units, mechanicallyuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | app.c was 7k lines wearing section markers; now the markers are files, cut exactly along them, no behaviour change: app.c the session core: config in force, pane and tab lifecycle, walking, input routing, introspection app_layout.c layout, tree edits, resizing, equalising, rotating, focus app_session.c layout files, checking, workspaces, dumping a session app_draw.c drawing, the shader passes, kitty graphics app_ui.c the finder, modals, the palette, the project picker, renaming include/app_internal.h carries what the five share: the tree/tab/app structs, CFG and the palette macros, and the 50-odd formerly static functions the cut made cross-unit, declared where they are defined. They keep their names; only the five units see the header. One test moved with the code: test_portable_layouts' staleness check greps the source for the layouts section, which now lives in app_session.c. Mechanical, so blame for anything in the four new files continues in app.c before this commit.
* slosh: defaults most people can live in, said by shadersuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The out-of-the-box look, rethought as one piece: - the compiled-in palette calms down: muted blue #7aa2f7 on the same neutral darks, with the colours that mean something kept semantic -- amber bell, red-orange dead. sl0p stays one include away as the house style, and contrib/themes/default.kdl is the starting point written down (theme-tour shows it). - derived state defaults now actually follow the theme: the scrolled wash is rebuilt from scroll_bg after the theme block (it used to bake the compiled accent), and declaring a state -- in any file of an include chain -- still wins. The dump writes derived states as comments so C-a e cannot pin what is meant to keep following. - a bell you can see: the frame blinks three times in theme's bell colour then breathes until answered, and a soft diagonal shimmer -- the bell colour halved towards default_fg, width breathing on a sine -- crosses the body for the same 900ms. bell_indicator false silences all of it; states { bell { } } replaces it like any state. since carries the clock dependency, so an idle session pays nothing. - scrollback says where it continues: while scrolled, the viewport's top and bottom rows melt towards an edge with content past it and turn solid at the buffer's end. New expression variables above/below (lines hidden past each edge, own dependency bit, cached per scroll position), carried in shade_ctx_t -- SLOSH_SHADER_ABI 3 -> 4. - expressions survive the dump: a program keeps its source and cb_chain writes it back, so amount="(y % 2) * 40" no longer dumps as a number. - headers move to include/, -Iinclude everywhere; the plugin Makefile's SLOSH var becomes SLOSH_DIR because slosh exports SLOSH=1 into every pane it runs, and building from inside a session read that as a path. - comment drift culled where features outran the prose, and the derivation boilerplate folded into derived_pass()/ctx_scroll().
* slosh: rename the project from sl0pptyuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | sl0p + pty was error-prone to type and the zero made it worse; slosh (slop + sh) is one syllable, impossible to typo, and slosh.foo is ours. Mechanical rename everywhere it appears: binary, env vars (SLOSH, SLOSH_SESSION, SLOSH_BIN, SLOSH_CONFIG), socket dir (/tmp/slosh-<uid>, $XDG_RUNTIME_DIR/slosh), config path (~/.config/slosh), header guards, src/sl0ppty.h -> src/slosh.h, contrib/sl0ppty-dev -> contrib/slosh-dev, the driving-slosh skill, docs and tests. The sl0ppi fork references and the sl0p theme stay: one is history, the other is the brand. The rename surfaced a latent bug in test_osc5577: the hello-reply answer always appeared on screen twice (ECHOCTL echo plus cat -v), but a seven-character implementation name happened to wrap the second copy across the 60-column boundary, so counting substrings in wrapped text found one and passed for the wrong reason. A five-character name moved the wrap and exposed it. The loop guard itself is correct -- with echo off before the request there is exactly one reply -- so the test now counts on de-wrapped text and tolerates the pane's own echo.
* sl0ppty: aim the top handle at the middle, and stop ruling through the rowuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two faults that fed each other, and only the second one made the first visible. The handle went to the **widest** free run of the top row. A centred title leaves two runs of nearly the same width, so a one-cell difference nobody can see threw the handle out toward a corner: on a 116-column pane whose row middle is 60, it sat at 85. Placement should be stated in terms of where the thing wants to be, not of the leftovers it is allowed to occupy -- so it now aims at the middle of the row and slides into the nearest run that is free. Nothing in the way: centred, like the other three sides. A title holding the middle: hard against the title, where the eye already is. That same pane now puts it at 64. The armed edge also ruled the heavy line straight across the whole border, over the pane's name, its buttons, its scroll indicator and a dead pane's epitaph. The row went unreadable exactly while the pointer was on it -- and the title the handle had just been placed beside was wiped, which is what turned a merely-off position into an apparently random one. Each cell of the edge is now painted only if the hit list says the edge still owns it: the same question the handle asks, and the one the click will ask. The epitaph had to start claiming its cells to take part. It answers `panestatus:` and does nothing when clicked, because an epitaph is not a button; what it buys is that ` exited: status 3 ` survives a hover over the border it lives on, next to `[re-run]` and `[close]`, which already survived because they were buttons. Verified: `make retest`, **1877 checks measured**, all green. Four new: the handle is centred on the row when the row is empty, sits against the title when the title holds the middle, and the armed top edge leaves both the name and the three buttons legible. Live on a 116x36 pane, hovering the far left of the top row -- deliberately the rim, so this is the first stage doing all of it: ╭━━━━━━━━━━━━━━━━━━━ sl0ppty ███▲███━━━━━━━━━━━━━━━━━▬ □ ✕ ╮ and on a dead pane, hovering the bottom handle: ╰ exited: status 3 ━━━━━━━━━━███▼███━━━━━━━━[re-run]━[close]━╯
* sl0ppty: the split arrow belongs on the buttonuser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The arrow rode the middle of the dashed boundary line, which put the two halves of one message in two places: the handle said *press here* and a glyph out in the middle of the pane said *this is what happens*. It also meant the direction was unreadable until you were already on the target, since the dashed line is stage two of the hover. So the arrow moves into the handle and the boundary line goes back to being a plain boundary -- which is the honest shape for it anyway: it is the *edge* of the pane you are about to make, and an edge has no middle worth marking. The handle is now one object that explains itself, and because the handle is drawn from the first stage, brushing any part of an edge tells you where its button is and which way that button splits, without previewing a boundary nobody asked about. Knocked out with ATTR_INVERSE rather than drawn in a second colour: the bar is solid `guide`, so a `guide` glyph on top of it would have been invisible, and inverting gets contrast that follows whatever colour the theme sets for no new knob. Along the handle the arrow sits in the middle, where the pointer is aiming. Across it -- a distinction only the upright pair has, being the two that grow a second cell -- it sits on the edge's own cell rather than the one over the content, because it marks a boundary and the boundary lands on the frame. Verified: `make retest`, **1873 checks measured** (`test_split_ux.py` 100 of them), all green. The test that pinned the arrow to the dashed line is now the test that pins it to the handle, on all four sides, plus one that it is legible from the first stage with no boundary drawn. Checked live on a 116x36 pane: `◄█` at column 2 and `█►` at column 117 on the middle row of their bars, `━━━███▼███━━━` on the bottom border's single row, and -- the case that shows why this is worth it -- hovering the *far left* of the top row draws `███▲███` at column 85, where the handle actually is, having dodged a centred title. A note on the two commits before this one: their messages quote 1801 and 1807 checks, which were carried over from earlier in the session rather than measured. The figure was 1865 at that point. The code and the tests were right; the numbers in those two messages were not.
* sl0ppty: a horizontal handle gets heavier, not talleruser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | The armed handle grew by one cell into the pane on all four sides, which is one change on two axes and therefore two different results. A cell is about twice as tall as it is wide -- the fact `gap_aspect` exists for, and the one the split picker measures its longer side by -- so the extra column on a left or right edge read as a thicker line, and the extra row on a top or bottom edge read as a wall. It also spent a row of somebody's output to say something the glyph was already saying: `█` is the heaviest thing one cell can draw, and against `━` it needs no help. So only the upright sides widen now. A horizontal handle stays on its own row. That also removes its claim on a content cell, which is why the inner-strip hit is registered on those two sides only -- the paint and the hit still describe the same rect, which is the whole reason the guide registers what it thickens. Verified: `make retest`, 1807 checks, all green -- six new, pinning the asymmetry in both directions: the top and bottom handles occupy exactly the row their border is on, the left and right ones exactly two columns, and the inner column of each upright handle hit-tests as that border rather than as the pane. Measured on a live session rather than argued: hovering the bottom border of a 116x36 pane puts seven block cells on row 37 and no others, inside the `━` rule; hovering the left border puts them on rows 16-22 across columns 2 and 3.
* sl0ppty: the middle of a border is the button, not all of ituser2026-08-18
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Clicking a pane's border to split toward it is the right gesture and was the wrong target. A border is the longest thing on the screen and it sits between two things you did mean to click -- a title, a gap, the pane beyond it -- so reaching a cell too far changed the layout, and the top row was worse than the sides: clicking a pane's header to focus it split it upward. So the target shrank. Each side carries a *handle* of three to seven cells in the middle, and only the handle splits; the rest of the edge is the *rim*, which arms the guide and does nothing when pressed. Three is the floor the pane buttons already settled on for the reason written there -- a one-cell target is a thing you miss with a mouse -- and seven is the ceiling, because a third of a tall pane's border is most of that border again and brings the accidents back with it. Hover now answers two questions in two stages, because they are two questions. Anywhere on an edge, the edge goes heavy and the handle thickens by a cell into the pane: that says where the button is, which is the thing you can no longer infer. On the handle, the dashed boundary and the arrow as well: that says what the click will do. The hint follows the same split, so sweeping an edge neither draws a boundary nor offers a split you cannot get from where the pointer is. Three things this had to get right. **The thickened handle is registered, not just painted.** The guide adds the hit for the inner strip in the frame that draws it, and reads the outer rect back from the hit list rather than recomputing it, so there is one opinion about where the handle is (D6). A handle you can see and cannot click would be worse than either mistake alone. **The top row cannot put its handle in the middle.** The buttons own its right and a centred title -- the default -- owns exactly the middle. The title winning is the existing rule, and it is what keeps a double-click rename from splitting twice on its way, so the handle is placed last, in the widest run of cells the row has left and nearest the middle among equals. A row with nothing left offers no upward split; its other three edges are unaffected. Each handle cell is then painted only if the hit list still agrees it belongs to the handle, so a block never lands on a pane's name. **The row keeps the drag and loses the split.** `title:<id>` takes no side now and `title:<id>:t` -- the handle -- carries one, which is why the press path reads the side out of the action instead of assuming it. Press-and-hold skips the dwell on a handle, where a press means something, rather than anywhere on an edge. Verified: `make retest`, 1801 checks, all green. Nine of them are new or rewritten: the two-stage guide, the caption belonging to the handle, the rim splitting nothing on any of the four sides, the top row's handle still splitting upward on a titled pane, and the rim of the top row not. Tests read the handle's cell out of the hit list (`Snapshot.handle`/`.rim`) rather than deriving the geometry, so a test cannot agree with a broken formula. Also checked by hand on a live session, which is where the placement rule earned itself: hovering the rim draws the heavy edge and the block and no dashed line, hovering the handle adds the line and the arrow, three clicks on pane headers split nothing, and on a pane titled `sl0ppty` the top handle lands to the right of the title and left of the buttons -- `drag to move · click to split up` at x=42, `drag to move` either side of it.
* sl0ppty: clang-format the treeuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The bumpy one. `make fmt` over every `.c` and `.h` outside `vendor/`: 32 files, 1,843 lines rewritten, no behaviour changed. Nothing here is worth reading -- the next commit adds it to `.git-blame-ignore-revs` so `git blame` walks past it. Two thirds of it is alignment the formatter now owns: trailing comments in a struct, continuation lines under a signature. The rest is the hand-packed tables opening up to one entry per line -- `ACTIONS`, `NAMED_KEYS`, `ACTION_HELP`. That is a real loss of density and a real gain in diffs: adding an action was a reflow of the block and is now one line. Anything worth keeping packed can be fenced with `// clang-format off`; nothing was, on the grounds that a table is the thing a formatter is for. **One change is not formatting.** `src/config.h` had three trailing comments glued onto one field: uint16_t hover_delay_ms; /* how long the pointer must rest to arm a guide */ /* how long an announcement stays up */ /* rows per wheel notch */ -- `toast_ms`'s and `scroll_lines`' comments, landed on their neighbour by an earlier edit, documenting the wrong things ever since. It is in this commit because it is what caught it: clang-format could not settle on that line, wrapping it one way and then re-indenting the wrap on a second pass, so `make fmt` was not idempotent. The comments are back where they belong and it is. Verified after the reformat, not before: `make clean && make all` is warning-free, `make retest` is 1801 checks green, and `make fmt` twice in a row changes nothing the second time. A formatter that alters behaviour would be worse than none, so the gate was the whole suite rather than a reading of the diff.
* sl0ppty: a dead pane says what it ranuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ``` │help: find a package to install `watch` with `cargo search cargo-watch` │ │ │ │[ran: cargo watch -x test] │ │[process exited: status 101] │ ╰ exited: status 101 ─────────────────────────────────[re-run]─[close]─╯ ``` `[re-run]` is one button on a pane whose program is gone, and nothing on the pane said what it would run. The command was never anywhere: not in the frame, not in the backlog, not in the exit note. Worst in a tab a project's layout built, where the command was never typed into that pane and so cannot be scrolled back to either -- which is now the common case, since that is what `open-workspace` does. So the death note gains a line above it. A pane running the session's shell has no command to name and gets the exit line alone, as before: `[ran: /bin/sh]` would be noise dressed as information. One note rather than two calls. `pane_note` puts a blank line either side of what it writes, so two of them would space the two halves apart as if they were about different things, when they are two facts about one run. Re-running keeps the previous run above it (D14), so the pair turns a pane you re-run into a log of what ran rather than a pile of identical epitaphs. Not put in the frame footer, which is the other place the question gets asked. It already carries the exit words and competes with the buttons for width, so a long command would arrive truncated in the one place that is always visible -- and the note is on screen anyway in the case that matters, because a pane whose program just exited is scrolled to the bottom. **Three existing tests were counting a string that now appears twice.** `DIES_CMD` is `echo mark-one; exit 3`, and they waited for `screen().count("mark-one") == 2` to mean "it ran twice" -- but the note contains the command, so every run now matches twice and the wait never finished. They ask `output_runs()` instead, which counts lines holding the marker that are not the `[ran:` line. Per line rather than by trimming the text, because every row still has the pane's frame border on the end of it. That is a fair correction and not a weakening: the assertion is still "the output appeared twice", which is what they meant. Verified: 56 checks in `tests/test_dead.py`, 1801 overall, all green. Three new: the command is named and sits *above* the exit line (asserted by row order, not by reading the two strings out of one screen); a pane with no command names none; and two runs produce two of each note. Then a real session, `command="cargo watch -x test"` with cargo-watch not installed, which is how this was noticed.
* sl0ppty: double-click a word to select ituser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pane_select_word()` was written and never wired to anything: no declaration, no caller, no way to reach it. This is the rest of it -- the gesture, the setting, and the tests. **The word is defined by what it stops at, not by what it is made of.** Anything that is not blank and not a listed separator belongs to a word, so CJK, accented letters and emoji need no table and come out whole; whitespace always separates, listed or not, because a double-click that selected the line would be a different gesture arrived at by surprise. Wide glyphs continue the run they are in rather than each being their own word -- otherwise every CJK character is a word. `word_separators` defaults to `"'`()[]{}<>;,|`, and what it leaves *out* is the decision: `/ . - _ : = @ # % ? & + ~ $` stay inside words, so a path, a dotted name, a snake_case identifier, a key=value pair, a URL and `src/app.c:1234` are each one thing you double-clicked to copy whole. `:` is the difference between copying an error location and copying half of one, which is why kitty keeps it too. The gesture reuses what selecting already has: the press starts a selection at the cell -- which anchors a drag out of the double-click -- then widens it to the word, and the release path that already copies a drag copies this. So the word *is* the copy, in the one place that rule lives, and the highlight is the copied span rather than an approximation of it. Tracked per cell as well as per pane, so clicking two different words quickly selects the second rather than everything between them. A program that asked for the mouse still keeps its clicks, with shift as the escape hatch, exactly as the drag has it. **One thing this needed on the way: quoted values were dumped unescaped.** The default separator set opens with a quote, and `cb_add(&b, "key \"%s\"\n", ...)` wrote it raw -- so `--dump-config` produced a file that no longer parsed, breaking the property the dump exists for. The escaping loop already existed inside `cb_chord`, for the same reason and with the reasoning written down; it is now `cb_qval`/`cb_qstr` and every string setting goes through it: the marks, `shell`, `editor`, `shader_dir`, `project_roots`, `project_layout`. Latent for all of those, load-bearing for this one. **And `--dump-config` does not do what four comments and the docs said it did.** It calls `config_dump_defaults()`: every setting with its *default*, never the value your config gives it. The function name was the honest one. Corrected in `docs/config.md`, `config/config.kdl`, `main.c`, `config.c`, `app.c` and the test's own docstring, rather than changing what the flag does -- starting a config from it is the documented use, and `--check` is what reads yours. There is no way to ask for the effective config; that is a gap, not a lie, and it can be a flag when somebody wants it. Verified: 22 checks in `tests/test_clipboard.py`. An error location, an identifier stopping at a bracket, a URL, a word inside quotes and a run of CJK; the highlight being exactly as wide as the copy; a separator, a blank cell and a single click each leaving the clipboard alone; two clicks on different cells not being a double; two slow clicks not being one; the separator set honoured from a config, including a quote and a backslash carried through KDL escaping; a word in a scrolled pane coming from the row on screen; and a mouse-tracking program keeping its double-clicks until shift takes them back. Three of those failed first because the test asked for the answer twice -- a second `worded()` call is a second pair of clicks -- and one because it mapped string indices to columns without counting wide glyphs as two.
* sl0ppty: `set-name target="pane"`, so a script can overrule a program's titleuser2026-08-17
| | | | | | | | | | | | | | | | | | | | | | | | | | A pane's name already outranks the title its program sets -- `pane_title` prefers the name while there is one -- and that is the whole remedy for a program announcing something it stopped meaning: an agent still spinning the summary of a task it finished, a build script that titled itself once. The remedy was reachable by double-clicking the title on somebody's screen, and from a layout file, but not from the socket, while `set-purpose` next to it has taken `target:"pane"|"tab"` all along. `target` defaults to `tab`, which is what this verb has always meant. Two verbs whose defaults differ is a smaller wrong than a verb that quietly changes its subject under scripts already written against it. The refusals say `no such pane` or `no such tab`, so a mistyped target shows up in the reply rather than as a label that did not move. The gesture now calls the same function, so the two ways in cannot disagree about where a pane's name lives -- there is a test that a name set over the socket is the name the double-click editor offers to edit. Verified: eight checks in `tests/test_pane_title.py`. A pane named while its program keeps setting OSC 2 titles keeps the name; clearing it hands the label back to what the program calls itself *now*; `id: 0` is the focused pane and leaves the other one alone; `set-name` with no target still renames the tab and leaves the pane's title alone.
* sl0ppty: 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: drag a pane onto a tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Drop it on a tab in the strip and it moves into that tab; drop it on the `+` and it gets one of its own. The fourth verb of the same drag machine, and it cost almost nothing because it is the third one's target read the other way round: the strip was already a row of drop targets, and a pane in your hand was already a thing being carried somewhere. The strip draws the drop states itself. `ptr_on` reports nothing during a drag -- on purpose, so hover feedback does not fight a gesture -- so a tab cannot find out it is under the pointer the usual way. It uses the two states the panes already use for this exact question: every tab the pane does not live in is a candidate in the drop colour, and the one under the pointer is filled. These are all targets; that is the one you are on. The tab the pane already lives in does not offer itself, because dropping there is the one refusal a drag can reach -- and it says "it is already in that tab" rather than looking like a drag that failed to take. Leaving the strip takes the offer back, the same way dragging a tab off the strip is not a cancel but simply not a place to put one. Releasing over a pane still swaps the two, which has a test of its own: the strip is a new kind of destination, not a replacement for the old one. A toast says where the pane went, and here it matters more than for the keys: the pane lands in a tab you are not looking at, so without a word the screen just shows one fewer pane. Verified: `tests/test_move_pane.py` at 52 checks. The new ones drive real mouse reports -- press on a title, motion onto a tab hit, release -- for the move, the `+`, the three highlight states (candidate, filled, and the pane's own tab declining), taking the offer back by dragging off the strip, the refusal, and that pane-onto-pane still swaps.
* sl0ppty: `C-a >` sends this pane to the next tabuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | C-a > push it to the tab after C-a b into a tab of its own C-a < push it to the tab before Three actions rather than three special cases in the key handler, which is what puts them in the cheatsheet and the palette without another line of code -- and the palette is where you find a thing you did not know to look for. You do not follow the pane, and a toast says where it went: "moved to tab 2 (api)". A key you might press twice needs to say what it did, and following would take you away from what you were doing. The tab's *name* is read before the move and its *number* after: emptying this tab removes it and shifts everything along, so the number read first is not the number anybody will see. When the tab you were in is the one that goes, you arrive in the survivor because there is nowhere to stay. `>` and `<` are the shifted period and comma, and the defaults bind both halves -- a terminal does not report shift for punctuation, so `>` arrives as a bare period the way `?` arrives as a bare slash. The config loader already did this for a chord you write by hand; the defaults had to do it themselves. The cost is that `.` and `,` are spoken for, which is the cost `/` already pays for `?`. `b` is for break out, the word tmux taught everybody for this. **Two things the tests caught, both mine.** `panes` reported a pane's tab as an index while every command that takes a tab wants an id -- and my own test conflated them, passing only because the two agree until a tab is removed, which is half of what these keys do. There is now a `tab_id` beside the `tab`: one is where it sits in the strip, the other is what you pass back. And three more rows overflowed the cheatsheet: `modal_frame` clamps the box to the screen, so at 56x44 the `session` group was simply not drawn. The sheet now says `+7 more, in docs/keys.md` when it cannot show everything -- counted by walking the rows it skipped, because arithmetic on two uneven columns claimed six were hidden on a screen showing all of them. A list that quietly stops is worse than a short one: the reader cannot tell that the key they wanted was on it. Reflowing to two columns when the height forces it would be better than a footnote, and is not this commit. Verified: `tests/test_move_pane.py` at 38 checks -- the keys in both directions, breaking out, the refusal when there is only one tab, pushing the last pane out and landing in the survivor, and that the sheet prints `>` rather than the bare period it also answers to while the palette finds it by label. `tests/test_help.py` gained the short-terminal case and lost an assumption about its own fixture height.
* sl0ppty: a pane can change tabsuser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":2,"dir":"rows"}' sl0ppty cmd '{"cmd":"move-pane","id":3,"tab":0,"name":"logs"}' The mechanism only. There is no key and no gesture for it yet -- that is the next conversation, and guessing at it now would mean building the wrong one twice. **The pane keeps running.** Measured, not assumed: `pid=3062557` before the move and `pid=3062557` after, in a different tab, with its scrollback intact and still answering what it is sent. That is the whole reason to move a pane rather than open one where you wanted it and close this one. It is tree surgery and it reuses the surgery that was already here. `place_beside` is the second half of `split_node_with` -- everything after "make the leaf" -- lifted out so that a leaf which already exists somewhere else can be grafted the same way, flat sibling or new split, on either side. It takes the tab rather than `cur(a)` now, because replacing a root has to land on the right one and `cur(a)` was only right while the sole caller was splitting the pane you were looking at. `detach_leaf` is `close_leaf` without the funeral. Three decisions worth naming: **The destination is a tab id, not an index.** A tab whose last pane leaves has nothing to be, so it is removed -- and every index after it moves. An id survives that; an index quietly means a different tab. The move re-finds the destination by id *after* the removal for exactly this reason, and there is a test that empties a tab and checks the pane landed where it was sent. **A move drops what the old tab thought about the pane** -- a zoom naming it, and its minimised flag. Both are the tab's opinion and not the pane's: carried across, the first zooms a pane that has left and the second files the arrival in a strip nobody asked for. **It does not follow the pane.** Which tab you are looking at is a question about intent, and this is the mechanism; `move-pane` answers with the tab id so a caller can select it if that is what it meant. "Into a tab of its own" is its own call rather than new-tab-then-move, because a pane that is the only thing in its tab would otherwise have its tab removed from under the new one, and the order those two happen in is precisely the bug. Verified: `tests/test_move_pane.py`, 25 checks -- the pane still running and still talking afterwards, placement beside and under the destination's focus, focus landing on the arrival, the emptied tab, a tab of its own, every refusal (no such pane, no such tab, already there, already alone), the dropped zoom and minimise, and after three moves a layout with no overlaps that dumps and rebuilds as itself.
* sl0ppty: the prompt speaks config, not a dialect of ituser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> include "contrib/chrome/heartbeat.kdl" heartbeat.kdl: 1 chrome, 0 content chrome> shaders { ...> tint where="chrome" color="#ff5fd7" amount="abs(sin(t / 5))" ...> dim where="content" amount=80 ...> } 1 chrome, 1 content Both of those were errors an hour ago, and the second one was my fault twice over: `:paste` printed `include "..."` and printing something you cannot type back is a prompt lying about its own output. Pasting it answered `unexpected argument for include`, and `load "..."` answered the same about `load`. So the grammar is now the config's, all of it: an entry, several with `;`, an entry with its own `where=`, a whole `shaders { }` block over as many lines as it takes, `include "f.kdl"`, `load f`. There is a test that reads `:paste`'s block off the screen, clears the pane, types it back line by line and compares the cells -- round-tripping is the property, not a nice-to-have. **The text is a document.** What it says replaces both chains, exactly as naming `shaders { }` in a config replaces the block rather than adding to it, and an entry's `where=` decides which rect it lands on. That drops a rule I invented last week -- "one chain, one rect", which refused an entry that named the other one -- because it made the prompt's mode a law rather than a default and made a two-rect block, the thing `:paste` prints, impossible to paste back. The prompt now keeps the document and sends all of it, so a line aimed at the frame leaves the contents alone, and the reply says `1 chrome, 1 content` rather than `ok`: with routing visible, the counts are the answer worth reading. `route_entries` is the one place routing is decided now -- config file, preset file, typed line, same function. `config_parse_chain_doc` takes the wrapper or the bare entries, because both are things a config contains and therefore both are things somebody will paste. Still nothing in the prompt parses KDL. A file goes by path; a pasted block goes via a temporary file, since an OSC payload cannot carry a newline -- a control byte ends the sequence, by the spec -- and joining lines with `;` would need exactly the comment-and-quote scanner I refused to write. The only syntax the script looks at is counting braces to know whether you have finished typing, and an empty line gives up on that. One hint, for the mistake that started this: when the session says `unknown shader`, the prompt adds `(a file? :load <path>, or include "<path>")`. After the fact rather than before, so a plugin's shader name is never second-guessed. Verified: `tests/test_shader_repl.py` is 41 checks -- every input shape, the multi-line paste and giving up on one, the round trip, the hint -- and `tests/test_inband_shaders.py` gained the document rule in both directions, including that a document naming only the frame takes the contents pass off. Full clean suite green.
* sl0ppty: `:load` a preset, without a second KDL readeruser2026-08-16
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | chrome> :load sine-comet sine-comet.kdl: 1 chrome, 0 content chrome> :paste // sine-comet.kdl include "/home/user/dev/sl0ppty/contrib/chrome/sine-comet.kdl" A path, or the bare name of anything in `chrome/` or `shaders/` beside the script, from any directory. Entries land wherever their own `where=` says, so one file can fill both chains and a preset goes where its author put it. Tab completes filenames after `:load` and vocabulary everywhere else. **The prompt does not parse the file.** I started writing the scanner -- find the `shaders {` block, count braces, strip `//` outside quoted strings so that `color="#ff0033"` and `t / 8` survive -- and stopped, because that is a second reader of a format that already has one, and the two would disagree exactly where comments meet strings. Instead the session gets the path and reads it with the parser it already has: `config_parse_chain_file`, built on the same `parse_shader_entry` a config file goes through. Anything that is not a top-level `shaders { }` block is ignored, so a whole `config.kdl` can be handed over and only its shaders apply. The alternative was regenerating all 45 presets into something easier to read from Python. That would have cost them what makes them useful: each one is a config fragment today -- pasteable, `include`-able, checkable with `sl0ppty --check`, and loaded as a config by `tests/test_chrome_presets.py`. A format only the prompt can read would be a fourth thing to keep in step. `:paste` after a load answers with an `include` line rather than the file's thirty: the file is on disk, it is already the block, and reprinting it scrolled the pane you were looking at -- which is how I noticed. Both chains are built beside the live ones and swapped in together, so a file that goes wrong half way leaves the pane as it was rather than wearing the half that parsed. Each compiled expression is matched to the chain whose shader points at it before either is installed; freeing a program the other chain still uses is the one bug here that would not appear until the next repaint. Verified: nine checks in `tests/test_shader_repl.py` -- a preset by bare name, a file with both rects going to both chains, the three ways to be wrong (no such file, no `shaders { }` block, no argument), what `:paste` says afterwards, and tab completion. Then all 45 shipped presets loaded by name in one session: none refused.
* sl0ppty: 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: 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: watch what the config was built from, not just what it was calleduser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An included theme that does not reload when you save it is half a feature, and the half that is missing is the one the file headers in contrib/themes have been promising all along ("a running session re-reads it the moment you save"). That was true while a theme was something you pasted; the moment it became something you include, the watch was on the wrong file. So a config now remembers every file it was read from -- itself and everything it included -- and the watcher watches all of them. Recorded before each parse rather than after, because a file that is not there yet is exactly the file worth watching: writing it is the next thing that will happen, and that is the same reason the watch is on directories rather than on files. Deduplicated, so a cycle does not fill the list with two names twice. The set is rebuilt after every successful load, because it is a property of the config that just loaded: an include can be added, pointed somewhere else, or taken out, and the watch has to follow. One inotify watch per directory, deduplicated by the descriptor inotify hands back for a directory it is already watching; names are matched against the whole set rather than against the directory whose watch fired, which means two directories holding a file of the same name reload for each other's save. That costs a debounced re-read of a config that has not changed, and is not worth a second data structure to avoid. Only the main config's directory is created if missing. An include's is not: a mistyped path should not leave a directory behind. tests/test_reload.py drives a real session for this one -- the watcher is the thing under test and the headless driver has none. It writes a config that includes a theme, checks the theme applied at startup, then edits *only the included file* and waits for the session to change colour.
* 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: measure chrome in columns, not charactersuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Good catch: a two-column bell mark shifted the pane title row. Everything in app.c that positions chrome -- title budgets, button placement, the hit rects that have to agree with them -- came from a `cells()` that counted codepoints, and a glyph drawn in two columns was booked as one, so every glyph after it landed a column early. Widths now come from lib-vt's own table, via ghostty_unicode_grapheme_width. That is deliberately the same table the terminal uses for pane content, so chrome and content cannot disagree about how wide something is, and it is cluster-aware: an emoji with a variation selector is two codepoints and one glyph. A wide cell claims its right half as a tail (width 0), which the renderer already knew how to skip. HANDOVER said this was a known trap and to dump the actual cells before touching arithmetic, which is exactly how this went: the first assertion I wrote passed against a deliberately broken width function, because it measured the frame's right corner -- drawn at an absolute position, so it does not move -- rather than the columns the mark claims. The test now reads the mark's own colour runs and demands 1 column for a narrow mark and 2 for a wide one, and it fails when the width function is stubbed back to 1. Two things fell out on the way: ghostty_unicode_grapheme_width measures only the *first* cluster and returns the codepoints it consumed. My first loop ignored that and asked "did the width change when I added the next codepoint?", which concludes that an entire ASCII string is a single cluster -- the cheatsheet modal collapsed to ten columns wide with the text clipped. Use the return value. A cluster can also be longer than a cell holds: a ZWJ family emoji is 18 bytes against 16. Both the cell writer and the config's mark fields (now 32 bytes) clamp on a codepoint boundary, because snprintf cuts at a byte and half a codepoint is invalid UTF-8 -- which showed up as a decoder error in a test far from the cause, not as a short mark.
* 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".
* sl0ppty: centre the middle of the status line on the rowuser2026-08-15
| | | | | | | | | | | | | | | | | | | | | It was centred in whatever space the two ends had left over, which meant it moved whenever a pane title, a session name or a state indicator changed length. The one thing on that line you might want to glance at was never twice in the same place -- which is most of the value of having a fixed place to put it. It is centred on the row now. And when the row is too full for it *there*, it is not drawn: not squeezed, not slid along, and not written over the ends. The first attempt at this did overwrite them, which read fine on a wide terminal and, on a narrow one, ate the words "exited: status 3" -- caught by a test for the dead-pane indicator rather than by anything looking at the status line, which is the good kind of accident. The rule that came out of it is the honest one: the ends say which session and pane you are in and are not written anywhere else on screen, while the middle is a hint you can get back by hovering or a version you can read a moment later. So the middle is the part that disappears. It keeps a blank column on each side while it is there, because touching reads as overlapping.