aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAge
* brew: pin the build to zig@0.16HEADmasterPeter Geissler7 days
| | | | | | | | | | | homebrew-core moved "zig" to 0.17.0, and the vendored libghostty-vt rejects any mismatched major/minor, so every brew install broke with "Your Zig version v0.17.0 does not meet the required build version of v0.16.0". Installing zig@0.16 by hand did not help: it is keg-only, so it is never on PATH, and the formula took the "zig" opt path regardless. Also stop hardcoding the version in the test block; it had gone stale at 0.1.0 and failed against the 0.1.8 binary.
* tests: clang-format and ruff catch up on two filesuser2026-10-09
| | | | | | No behaviour: argument wrapping in path_test, and one check() call in test_mouse_pixels that was over the line length. Both are what the pre-commit hook would have done anyway.
* osc 7501: say what you are doing in somebody else's protocoluser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | OSC 5577 only works for programs that know slosh exists. The program status protocol is the same idea written down for everybody, by ghostty's author and implemented by ghostty and Rex, so a program that already speaks it needs to know nothing about us: printf '\033]7501;state=working:app=cargo:msg=YnVpbGRpbmc=\033\\' Implemented *as well as* 5577, not instead of it. 5577 is a two-way control channel -- buttons answer on the program's stdin, shaders are refused with a reason -- and 7501 deliberately is not, so neither absorbs the other. What 7501 buys is the programs we do not control. The scanner becomes one pass for four things: 5577, 7501, OSC 133 ; A and RIS. The last two are here because 7501 ties record lifetime to them -- a new prompt or a full reset is how a stale `working` dies without a heartbeat -- and because a pane should pay for that scan once. osc5577_fn gives way to osc_scan_cbs_t. Reports are all-or-nothing, which is the spec's rule: no state, an unknown state, a broken limit, base64 that will not decode, a control character in decoded text or a malformed id discards the whole report rather than writing a partial one. A malformed *pair* is skipped and the rest parsed. A pane keeps up to PANE_PS_MAX records with LRU eviction, and composes the one line it has from them by precedence -- blocked, error, done, working, idle -- resolving `app` from the nearest ancestor that has one, the way the hierarchy is allowed to mean something. It lands in the slot 5577's `status` already had, because every reader of a pane wants "what is this doing", not "which protocol said so"; `panes` reports program_state, program_kind, program_app and progress beside it, with -1 for a progress nobody reported, since a bar drawn at 0% says something false. The feature-detection reply is rate-limited here. Note for the next re-vendor: upstream lib-vt implements 7501 too, newer than our pin, and answers the support query itself through write_pty without rate-limiting it. See "What upstream did" under D1 in DESIGN.md before swapping this half of the file for GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS.
* tab bar: a sidebar that fits the terminal, and gives up the edge lastuser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `tab_bar_width` was a reservation: the configured columns came off the screen whatever the screen was, and the only fallback was all the way back to the top row. On a narrow terminal that is chrome displacing the thing it is chrome for, and the jump to the top moves every target you were aiming at. It is a ceiling now. app_tab_bar_cols takes the app, because the answer depends on the terminal and not only on the config: held to the columns left after a pane's collapse width, and to a third of the screen, and narrowed rather than dropped. TAB_BAR_FIT (14) is the narrowest width this code will invent on its own -- a config that asks for less is taken at its word -- and 0 means there is no room for a sidebar at all, which is the one case that still falls back on top. Height counts too, which it did not before: a sidebar spends a row per tab, so a wide but short terminal runs out of list before it runs out of screen, and a tab with no row is not clipped, it is unreachable. tab_bar_min_rows is that floor. Not when the top strip could not seat them either -- moving them would gain nothing, so the side you configured stands. All of it is re-derived per frame, like the layout, so a resize moves the bar and nothing remembers that it did. test_tab_bar also stops depending on where the suite is run from. An unnamed tab borrows the basename of its focused pane's cwd, so eight checks read `tests` under `make test` (which cds there) and `slosh` from the repository root, where they failed. They are about padding, alignment and the index, not about that derivation, so they name the tab first -- by id, since `set-name` takes 0 for the focused *pane* only. 147 checks, green from either directory.
* panes: the focus a closed tab left behinduser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A tab's focus outlived the tree it pointed into. close_leaf's last-pane branch freed the leaf and removed the tab but never cleared `t->focus`, and tab_remove only shifts the slots *after* the one it removes -- so with the last tab gone, `a->tabs[0]` kept the stale tab_t while `ntabs` was 0 and `cur` was 0. `cur(a)` is `&a->tabs[a->cur]` with no bounds check, and `a->quit` is a request rather than a barrier: the loop keeps serving commands, and the next one to ask for the focused pane was handed freed memory. From outside it looked like a random crash, because what the freed chunk held depended on what had been allocated since. Two from one afternoon: a pane_t recycled into a `panes` JSON reply, whose `term` pointer was then the ASCII of `k":true,` and faulted as a general protection fault; and the same node read back zeroed, which left pane_clear_bell testing a flag at NULL+0x5d. Typing `exit` in the last pane is the whole recipe. close_leaf drops the focus with the tree tab_remove blanks the slot the shift vacated app_compose tests ->pane as well as ->focus, the way the search beside it already did app_apply_layout clears restore_focus up front -- it is a node pointer, and an apply that fails before consuming it leaves it naming a tree the next apply frees Two more the same reading turned up, both in close_leaf: an emptied split set the focus to the node just freed, and a leaf whose parent disagreed with that parent's child list underflowed `p->nkids - at - 1` into a memmove of nearly SIZE_MAX. FOCUS_CHECK -- behind -DSLOSH_FOCUS_CHECK, compiled out otherwise -- is the invariant all of this turns on: every tab's focus is a leaf of that tab's own tree. It is what found this one. Committed with --no-verify: the files are deliberately partly staged, and the staged blobs were checked against clang-format directly.
* skill: which pane am I, and how not to land in somebody else's tabuser2026-09-29
| | | | | | | | | | | | The skill described the API and left the one question an agent actually gets wrong unanswered. It now leads with it: your pane is $SLOSH_PANE, `focused` is the human's, and the fallback when the environment is scrubbed is `pid` -- never focus. The rest follows the verbs that shipped with it: `capture` for reading a pane in any tab, `raw id` for typing into one, `apply-layout focus:false` and `move-pane beside` for building work beside yourself without dragging the view along.
* docs: the pane you are in, the pane you name, and not moving the viewuser2026-09-29
| | | | | | | | | | | | $SLOSH_PANE in the man page's environment list, next to SLOSH and SLOSH_SESSION, and the scripting guide grows the section that follows from it: read yourself with `capture`, build with `apply-layout focus:false`, place with `move-pane beside`, and never read `focused` as "where do I live". `pid` is written down as the answer for anything that cannot read its own environment. (Three roff \(em escapes had got into the markdown table; they are em dashes like the rest of the file.)
* layouts, moves: build panes without pulling the view to themuser2026-09-29
| | | | | | | | | | | | | | | | | | | | | Applying a layout landed on the first tab it made, and moving a pane into a tab took focus there. For a program building a pane in the background that is two failures at once: it yanks the view off the human mid-sentence, and it leaves the *next* caller to read focus as "where I am" and put its work in a stranger's tab. `apply-layout focus:false` builds the tabs and keeps looking at whatever was on screen; a layout naming an `active` tab still wins, because that is a statement about where to be. `move-pane` gains `beside` (which pane in the destination to split, rather than whatever it has focused) and `focus:false` -- the two things a caller placing a pane next to *itself* needs. That uncovered a sizing bug that was unreachable before: a pane's size comes from a layout pass, and a tab nobody has looked at never had one, so a pane built in the background stayed 1x1 and its program drew into a single cell. The jump is what used to size it. layout_tab() gives a background tab a real pass, borrowing the tab cursor for its length -- nothing composes, reads input or draws a frame in between.
* panes: read and write a pane by id, without taking focususer2026-09-29
| | | | | | | | | | | | | | | | | | `raw` wrote into the focused pane and `snapshot` composited the active tab, so driving a pane anywhere else meant focusing it or selecting its tab first. Both of those move the view of whoever is watching, and both lose to anybody doing the same thing at the same time. `raw` now takes an `id` (0 is the focused pane, as before), and `capture` answers one pane's viewport as text -- from the pane's own terminal, so it works for a pane in a background tab, changes no focus, and leaves the human's selection alone. It builds its selection as a snapshot rather than setting one on the terminal, because a read with a visible side effect is not a read. `capture` tells "no such pane" apart from "that pane is empty": a caller polling a dead id would otherwise wait forever on a pane that is quiet because it is gone.
* panes: $SLOSH_PANE, so a program knows which pane it is inuser2026-09-29
| | | | | | | | | | | | | | | | | | | | A program in a pane could name every pane but its own. `panes` says which one is *focused*, and focus belongs to the human, so anything that inferred "where do I live" from it did its work in whichever tab somebody else happened to be looking at -- and lost a race with anybody else asking the same question. So export the id into the child, the way tmux has $TMUX_PANE and zellij $ZELLIJ_PANE_ID. The id is taken before the pane is made, because pane_new spawns the program and assigning it afterwards is one instruction too late; it lives on the pane rather than the layout, so a rerun keeps it -- an identity that changed under a rerun would be no identity at all. Unset, not zero, when there is none: a stale value inherited from an outer session would read as this one's. `panes` also reports each pane's pid now, which is the same question answered for anything that cannot read its own environment: walk your parents up until one of them is on that list.
* mouse: encode pixel reports against the cell size we advertiseuser2026-09-29
| | | | | | | | | | | | | | | | A pane is told how big a cell is twice -- in mode 2048's resize report and in the XTWINOPS answers -- and the mouse encoder was told something else: a 1x1 cell, so that cell coordinates and "pixels" were the same number. That held until a program turned on SGR-pixel mouse (mode 1016) by itself. Textual does, the moment in-band resize works, and then divides every coordinate it is sent by the pixels-per-cell we advertised: a click 30 cells across and 9 down arrived as (3, 0), so every click in a Textual app landed in the top-left corner. Hand the encoder the cell size the pane already has, and put the pointer in the middle of its cell. A size that contradicts what the same pane was told about itself is the lie, not the precision.
* skill: the verbs that shipped since, and the bare form's other shapeuser2026-09-29
|
* web: plainer front page copy -- themes, effects, scripting, floats, installuser2026-09-24
|
* release: v0.1.8 paperwork -- formula and PKGBUILD pinneduser2026-09-22
|
* release: v0.1.8v0.1.8user2026-09-22
|
* webdemo: the picker in the machine, and a guest that proves its themesuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | The demo image was cut before any of this existed: an old guest binary with no `C-a t` in it, and a config whose theme was a pasted palette. Rebuilt -- guest binary, root filesystem, site -- so the thing a visitor lands in is the thing the front page is now claiming. The overlay already kept every shipped theme in ~/.config/slosh/themes (they are symlinks into contrib/, copied by content because a symlink in an ext2 image would dangle), which turns out to be exactly the directory the picker looks in first -- and in the guest it is the *only* one, since /usr/bin/slosh has no share/slosh beside it. So `boot-check` now asks the guest itself: `{"cmd":"theme"}` over --script, expecting the worn theme and the shipped list back. It failed honestly the first time, for the right reason (no HOME in a phase that boots straight to /bin/sh, so no config, so no themes/ beside it), which is the kind of failure a check is for. The welcome float grew a fourth line -- `C-a t`, every theme, worn as you browse -- and a row to hold it; the motd and the config's own comments say the same. A visitor who reads three lines and presses one key should land on the feature the page just sold them.
* web: a recording of the theme picker, and the tour step for ituser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The landing page's feature tour is scripted recordings -- driver scripts against `slosh --script` on a virtual clock, re-rendered by `make` so a UI change can never leave a stale demo -- and the picker is exactly the kind of thing that does not survive being described. "Moving the selection applies the theme" is a sentence; twenty-six seconds of a session changing colour under a list is the claim itself. demos/themes.py: a sidebar session with two tabs and a build pane that says it is busy (so the spinner is in shot, turning in whatever colour the theme being tried says it should), `cat config.kdl` showing a config that names a theme and holds no colours at all, then C-a t -- the list, two themes worn rather than swatched, `pho` typed to narrow, C-s writing the one line, a second `cat` proving it wrote that line and nothing else, and finally a browse that ends in Escape, which puts back what was worn. Two rules the demo had to obey, both now in PLAN.md beside the other lessons: only dark themes, because cells slosh does not paint keep the terminal's own background and the cast header fixes that once -- `paper` would look broken in the player and fine in a light terminal; and nothing on screen may carry this machine, so the themes are copied next to the temp config rather than pointed at with `theme_dir`, leaving a `cat` frame that reads as a name and a directory nobody has to have. The step slots after "make it yours, live", which is where a reader has just been shown a config being edited by hand and is best placed to notice that a theme is one word of it. Casts are built at publish time, so build-site picks the new one up with no wiring.
* theme: the busy spinner is its own colour (tab_status_spinner)user2026-09-22
| | | | | | | | | | | | | It was drawn in tab_status_busy, the colour of the words it sits beside, which made one decision out of two. The words have to stay readable at a glance and a mark does not: a theme may well want the glyph loud and the text calm -- or the text in the accent and the mark a quiet dot -- and with one entry neither is available without moving both. Unset it follows tab_status_busy, derived after it and from whatever that resolved to, named or derived, so nothing changes for anyone who does not ask and no theme in contrib needs a line. `--dump-theme` writes both, like every other colour the compositor draws.
* sidebar: room for the spinner, by default (tab_bar_padding 0 1 0 2)user2026-09-22
| | | | | | | | | | | | | | | | | | | `│⠋bash: cd` -- the mark landed in the one column of left padding there was, so it ended up welded to the first letter of the status. The rule was "the mark takes a column the padding was already holding blank", which is right about not reflowing and wrong about there being enough of it. So the mark takes the *first* column of the indent and whatever is left over is the air after it, and the default indent is two columns instead of one. One column is still available -- `tab_bar_padding 0 1` -- and now means the squashed look on purpose rather than by default; three gives two spaces; zero still puts everything against the frame. Nothing reflows when a program starts or stops working, which was the point of taking a column that was already blank. The labels and the tab numbers move with it, because the indent is the strip's and not the status's -- a status still starts in the column its tab's name starts in, which is the rule the leading space existed for in the first place.
* osc 5577: `busy`, and a sidebar that shows it (busy_mark)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A status is a line of text, and a line of text cannot say whether it is still true. "building 3/7" reads exactly the same while it builds and ten minutes after it stopped, which is the one thing somebody reading it from another tab wants to know -- and the only workaround available to a program was to write the answer into the words and keep rewriting them. So `busy`: printf '\033]5577;1;status;make test\033\\' printf '\033]5577;1;busy;1\033\\' ... printf '\033]5577;1;busy;0\033\\' A flag of its own rather than a field of `status`, because a status is the *whole* payload after the verb -- `status;a;b;c` has always been the text "a;b;c" -- so anything added to that line would change what every existing sender means, including the five pi extensions D1 promises byte compatibility to. Orthogonal, so a program can leave one line up and toggle the other; anything but 0/false/no/off is on; `clear` takes both; and a pane whose program has gone is never busy, whatever it last claimed, because a spinner turning under a dead tab is a lie you keep looking at. In a sidebar the row turns `tab_status_busy` -- the theme's accent, derived like the other status colours, so no theme needs a line -- and takes a spinner in the column `tab_bar_padding` was already holding blank, so nothing reflows when a program starts or stops working. `busy_mark` is the frames, separated by spaces: one frame is a static mark and costs nothing, several make it turn, and only then does the session keep a frame clock -- via the same `animating` flag an animated shader uses, so the cost lands only while such a row is actually composed, and `""` drops the mark entirely and leaves the colour to say it. The frame is a pure function of the clock, like every other pulse here, so two clients attached to one session show the same one. `panes` now reports `status` and `busy`, which is the scriptable half of the same fact: what a pane says about itself, without reading its screen.
* sidebar: air inside it, the way panes have padding (tab_bar_padding)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `padding` is a pane's: the ring between a frame and the text it holds, in rows times gap_aspect so a `1` looks square around a block of prose. Handing the same knob to the sidebar looked tempting and is wrong twice over. The sidebar is chrome, not a pane, and its width is fixed by tab_bar_width -- so `padding 1` would quietly eat four of a sixteen-column strip (two cells a side, aspect included) and shorten every tab name, because somebody wanted their terminal text to breathe. And its two axes are not one idea: the columns are an indent for labels, the rows are air above a list. So tab_bar_padding, its own knob in the family the rest of the sidebar's air already lives in (tab_bar_pad, tab_gap, newtab_pad), taking the same 1/2/4 CSS-order values as `padding` -- one reader for both, so they cannot drift into meaning different things -- but counted in cells. The default `0 1` is what the sidebar already drew: that one column is where every label's leading space has been hard-coded since the sidebar existed, and moving it into a setting is what makes `tab_bar_padding 0` possible at all -- names flush against the frame, which was not previously a thing anyone could ask for. The statuses, the `+ new tab` row and the pane count follow the same indent, since they were each carrying their own copy of that space. It is air *inside* the plate: a tab's fill still spans the whole row and the whole row is still the hit, because the row is what you click and a highlight that stopped short of the frame would read as a highlighted word rather than a chosen row. The bottom rows are given up when the strip is short of them, like every other piece of air in here. tab_bar_pad is the older name for the top component alone. It still works, still means exactly that, and wins where a config writes both -- it is the more specific of the two. The dump writes tab_bar_padding and mentions it.
* sidebar: align the numbers by defaultuser2026-09-22
| | | | | | | | | | | | | | | | | | | The knob landed defaulting to "prefix" on the grounds that it is what the strip has always drawn -- but the strip is not what this is about. A sidebar row is wider than the name on it, and `1:api` spends the first two or three cells of every row (three or four once you have ten tabs) on a number nobody reads until they reach for C-a 2, pushing the names in by a distance that depends on how many tabs are open. Right-aligned, the names start where the statuses under them start and the numbers make a column of their own. `tab_bar_index "prefix"` is still there and unchanged, and the top strip is unaffected either way -- it ignores the setting, having no far end to align against. The tests that used `1:` as the anchor for "where a tab's name starts" now anchor on the name, which is what they were about; two new ones cover the default and the prefix form explicitly, so neither mode is tested only by being the default.
* sidebar: the tab number at the far edge (tab_bar_index)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `1:api` puts a number at the start of every row, and in a sidebar that is a column of digits in front of the thing you are actually reading -- the names start one, two or three cells in depending on how many tabs you have, and the numbers are only wanted at the moment you reach for C-a 2. tab_bar_index "right" moves them: │ tests 1 │ │ 1:tests │ │ verylongproj 2 │ vs │ 2:verylongproje│ │ api 3 │ │ 3:api │ Every name starts in the same column and the numbers line up in one of their own, which costs a column of sidebar and buys the list a shape. "prefix" stays the default, because it is what the strip has always drawn and the strip is where most people meet it. Sidebars only, and not by omission: a tab in the top strip is exactly as wide as its label, so there is no far end to align against and a number floating after a name it does not belong to would be worse than the prefix it replaced. The setting is ignored there rather than half-honoured. A row too narrow to spare the columns (under the number plus five) also keeps the prefix, since the name is the part you came for, and the rename editor keeps the whole cell as it always did. A name long enough to reach the number stops one cell short of it, because `verylongproje2` is a tab called verylongproje2 as far as any reader can tell. The cell comes back as padding, so the plate is unbroken and the hit is still the whole row -- clicking the number is clicking the tab, and the number brightens with the label under the pointer rather than leaving half a row lit. The bell mark keeps its place between them, budgeted first as before, so a long name cannot eat either of the two things that are not the name.
* themes: ask the binary where it is, not the prefix it was built foruser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `theme_name "sl0p"` with all seven themes sitting in /usr/share/slosh/themes: "no theme named sl0p". The lookup knew two places -- your own themes directory and the PREFIX this build was compiled with -- and on that machine the first did not exist and the second was /usr/local, because that is the Makefile's default and the package was built with PREFIX=/usr. Neither was where the themes actually were, and nothing in the search could ever have found them. A compiled-in prefix is a guess about where the binary will end up, and the binary is the one thing that knows where it really is. So the search now asks it: path_self() (/proc/self/exe, _NSGetExecutablePath, GetModuleFileName -- lifted out of server.c, which was already doing this for SLOSH_BIN) gives <bin>/../share/slosh/themes for an installed slosh wherever it was installed to, and <repo>/contrib/themes for one being run out of a build tree, which makes a checkout able to name a theme without installing anything first. The compiled-in prefix stays as the last candidate, for a binary sitting somewhere that says nothing about its data, and duplicates collapse so the usual install does not report the same directory twice. Your own directory is still first and still shadows everything, which is the property that matters; the rest is about the shipped set being findable at all. The tests that assumed a closed world of two temp themes now narrow to their own names by typing, since the shipped ones are (correctly) always in the list -- and the "no themes anywhere" toast lost its test, because with the shipped set reachable that state takes a binary with nothing beside it. The case that started this is a test of its own.
* themes: a picker that wears them as you browse (C-a t)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `theme_name` is a line in a file, and a name in a list tells you nothing about a colour scheme. So the picker previews by *applying*: moving the selection puts that theme on, which is the only honest preview of a palette meant to dress a whole session -- a swatch in a list would be a small lie about it, and the session is already on screen behind the box. It opens on the theme being worn rather than at the top, so the first arrow press moves off it instead of silently changing the session, and the row your config names says so, because "which one is this" and "which one comes back on restart" are two different facts the moment you switch. Escape puts back what was worn, since a picker that changed your session by being looked at is one you stop opening. Enter keeps it for the session and says as much. Only C-s writes `theme_name` into your config, which is the one place in the UI where a keystroke edits it, so it is a deliberate chord and the footer says so: "C-s keeps it, esc undoes". Reuses the picker the finder, the palette and the projects list already wear -- one machine with a subject, so the query, the selection, the mouse, the frame and the scroll behaviour are the ones already tested. The list is scanned on the keystroke, like the projects are: the moment it opens is the moment it has to be right, and a theme written since the session started is exactly the one being looked for. With nothing to list it toasts the directory it looked in rather than opening an empty box. `t` was free: tabs cycle on tab, and new-tab has had `c` since the first day.
* theme: a name and a directory, not sixty colours in your configuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Wearing a theme meant pasting its `theme { }` block into config.kdl, or including a path to a file you had copied there yourself. Both make the palette a literal in your config, and a literal is the one thing you cannot change cheaply: not from a script, not from a running session, not by a list of what is available, and never again from upstream -- the colours you pasted are the colours that theme had the day you pasted them. So a theme is a file in a directory now, and a config names it: theme_name "phosphor" theme { frame_focus "#00ff88" } // ...but that one colour is mine theme_name is applied after the includes and before everything the file says itself, which is the rule includes already follow and for the same reason: the loader reads a document by asking it for names rather than walking it in order, so "here" is not a position it could honour, and "what you asked for is the base, what you wrote is yours" is the rule worth having. Two directories, yours first: `themes/` beside the root config -- beside the file that was loaded, so a config somewhere else (SLOSH_CONFIG, or one handed to --check) takes its themes with it -- and then <prefix>/share/slosh/themes, compiled in from PREFIX. install(1) has been putting themes there all along and nothing could read them; now a shipped theme is nameable without being copied, and a file of yours with the same name shadows it, which is how a root-owned theme gets edited. A name, not a path. The directory is what makes a theme switchable, listable and shadowable, and a path silently does none of those, so `theme_name "../x.kdl"` is refused with a line pointing at `include`, which still works and is still the answer for a file somewhere a directory of names cannot reach. A theme that is not there is one complaint and no more (D9): the rest of the config applies, because losing your keybindings over a mistyped colour scheme would be the wrong trade. A theme file is read by the same loader, so it is watched like any other: saving one reloads the session wearing it, and the derivations (tab_status_fg, tab_idle_bg) run over the theme's own colours exactly as before. Switching without editing anything, over the socket a session already has: slosh -s main cmd theme # amber default* ... slosh -s main cmd '{"cmd":"theme","name":"amber"}' # now slosh -s main cmd '{"cmd":"theme","name":"amber","save":true}' The session holds the name over later reloads, so saving your config for an unrelated reason does not put the old colours back; `save` writes the theme_name line into the file, replacing the one already there and leaving every other line and comment alone -- dumping the whole config back over itself would reformat a hand-written file and drop its comments, which is not a fair price for changing a colour scheme. A name is tried on a throwaway config first, so a typo costs nothing. An empty name gives the answer back to the file. --dump-config stops writing colours: it writes the theme's name and the colours a config overrode by hand, so a dumped config keeps following its theme instead of freezing it. --dump-theme is the other half and is new: the resolved palette as a theme file, which is how you start one from the session in front of you rather than from an empty file. It is also what keeps contrib/themes/default.kdl honest -- that file is now the annotated reference (the commented palette moved there out of config.kdl, where it was 119 lines of colour in the middle of the prose), and a test compares it against the compiled-in one. While in there: tests/test_themes.py's "every theme sets every colour" guard had been grepping for `&c->`, the spelling before the colour table moved to offsetof, so it had been finding zero colours and passing for months. Fixed, with the derived and opt-in colours exempted for the reasons they are derived and opt-in, and with a check that the guard found a table at all.
* tabs: idle tabs you can actually read across the roomuser2026-09-22
| | | | | | | | | | | | | | | | | | | | | | Seen next to the accent, the first plate was too polite to do the job it was added for: a 30% lift off the background under labels in the same #45454a the idle frames use. From another monitor the strip still read as one dark band with a blue block in it. Both ends move. tab_idle leaves the ambient chrome grey and becomes #8a8a95, because a tab you are not in is a place to go and has to be legible at a glance, where a dimmed pane border only has to be findable. The plate follows it to 43%, which is not a taste constant but a contrast ratio: the same step taken from a dark background upward and a light one downward lands at about 1.9:1 either way, so paper's #bdbebe on near-white and the default's #3b3b40 on black are the same amount of plate. The six themes in contrib named their idle tab colour as their frame grey, so each gets the same move in its own family -- 40% of the way from that grey to the colour the theme writes text in -- and their plates follow by derivation: amber #a67c26, mono #909090, paper #666a6e, phosphor #4a9c5d, slate #70789c, sl0p the house #8a8a95. Nobody who named a colour loses it; the derivation only ever fills in a theme's silence.
* tabs: a plate under the ones you are not in (tab_idle_bg)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | The active tab has been filled with the accent since the strip existed; every other tab was words on the background. That leaves the strip with one shape on it and nothing to compare it against -- where a tab ends, how far the row you are about to click reaches, and in the sidebar which rows are tabs rather than statuses, all had to be read off the text. tab_idle_bg fills them. Barely off the background and in the label's own grey, because this is not a second highlight: it gives a tab edges, and it gives the accent something to be brighter than, which on a strip of short labels is most of how the eye finds the tab it is in. Hover keeps it for the same reason the active tab keeps its fill -- losing the plate under the pointer would read as the tab going away -- so hovering still lands on the text, and the bell mark sits on the plate rather than punching a hole in it. Derived a third of the way from default_bg to tab_idle when nobody names it, the same bargain tab_status_fg and tab_status_stripe already make: both ends are colours every theme in contrib defines, so each gets a plate in its own family -- paper lightens, phosphor greens, mono stays grey -- without a line added to any of them. Naming it wins, and naming your own background is how you ask for the bare tabs back. The `+ new tab` button and the gaps between tabs stay unpainted: the button is the one row in the list that does not mean "go here", and tab_gap is air between plates, which is what makes them read as separate tabs at all.
* sidebar: air between tabs (tab_gap)Peter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | A tab carrying three rows of wrapped status and the tab under it carrying three more read as one column of text: the labels are in there, but the list has stopped looking like a list. tab_gap is the blank rows between them, 0 by default, which is exactly what the sidebar has always drawn. It is the space *between* tabs and nothing else. Above the first tab is tab_bar_pad's and before the button is newtab_pad's, so the gap is applied before each label except the first and none of the three is ever paid twice. Given up a row at a time when the list is short of room, ahead of every label and the button, which is the same order the pad and the statuses already follow: a gap that pushed a tab off the bottom would be spacing costing you the thing it spaces. Paint rather than layout, so tuning it repaints without reflowing any pane. Sidebars only -- the top strip is one row, and the space between tabs there is the label's own padding.
* sidebar: a status can have more than one row (tab_bar_status_lines)Peter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Sixteen cells is not enough for a sentence, and a sentence is what a pane has to say: "add a summary…" is most of a status and none of its meaning. tab_bar_status_lines 3 word-wraps instead of cutting, and the same column carries forty-eight cells rather than sixteen. Wrapped, the rows under a tab stop being a list of panes and become a paragraph each, so the shape no longer says where one pane's status ends and the next begins -- both are italic text down one column. Alternate panes are drawn on alternate bands to say it instead: tab_status_stripe, derived from the theme like tab_status_fg, so a theme that has never heard of any of this still gets a band in its own family. Nothing is painted at one line, where there is no ambiguity to resolve and a band would take a translucent terminal's background away for nothing. 1 is the default and is the old behaviour exactly, down to cutting at the column rather than the last word before it: with no next row to move the word to, breaking early would spend cells on nothing. The reason to prefer a word boundary only exists once there is somewhere for the word to go. Rows count against tab_bar_status as before, so wrapping trades panes for words within the same allowance, and the allowance itself still gives way to the tabs. A status that will not fit whole is dropped whole and reported with the ellipsis, rather than shown as a fragment that reads like the whole thing. Every row of a status is the same `find:` door. tabstatus's buffer goes to 256 to match pane_status's own slot: at one line the drawing cut it anyway, but wrapped it is the whole string that has to survive the trip.
* sidebar: a talkative pane can no longer cost you a tabPeter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | Rows were spent in tab order until they ran out, so statuses from early tabs simply consumed the rows later tabs needed for their labels. Four tabs of three announcing panes in a sixteen-row terminal left the fourth tab with no row and no hit anywhere on the sidebar -- not clipped, unreachable -- and the new-tab button went with it. The list is the navigation and a status is an annotation on it, so the annotation is what gives way. Before drawing a tab's statuses, reserve a row for each tab still to come and one for the button, and allow the statuses only what is left over. The "and more" ellipsis is measured against that allowance rather than the cap, so a list cut short by the height still says it was cut, the same as one cut short by the setting. The pad is deliberately not reserved: it already gives itself up a cell at a time, which is the same order of surrender -- spacing, then extra statuses, then statuses, never the thing they are about.
* tabs: air before the new-tab button, and the option to do without itPeter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | | | The `+` is the one entry in the strip that does not mean "go here", and it sits at the end of a list where every other entry does. Flush against them it is what you hit when you meant the last tab -- most easily in a sidebar, where the target is the full width of the row and the pointer only has to be one row low. newtab_pad is the nothing in between: blank rows in a sidebar, blank columns on top, one by default, which is enough to make the slip a miss. It is paint rather than layout, like tab_bar_pad: the panes' rectangle is the same at 0 as at 5, so tuning it repaints without reflowing anyone. And it is given up before the button is -- a strip short of room drops the spacing one cell at a time and still draws what the spacing was protecting, because spacing must never cost you the thing it spaces. newtab_button false removes the button outright, for people who make tabs from the keyboard and would rather have the row (or the columns) back. Only the place to click it goes: the chord and the palette entry still work. Setting newtab_mark "" already did this by omission; this says it on purpose, and says it in --dump-config.
* tests: a pane's status is output, so write the pty, not the input decoderPeter Geissler2026-09-20
| | | | | | | | | | | | _status used `send`, which feeds slosh's input decoder -- the path a keystroke from the outer terminal takes. An OSC arriving there is a reply addressed to slosh, not output from the program, so the sequence never reached the pane: the status stayed empty and all five checks compared against whatever the row below the tab actually held, which is the next tab's label. `raw` writes the pane's pty, where `cat` echoes the bytes back and the pane's own terminal parses them -- the path the feature is for. test_osc5577.py has used `raw` throughout for the same reason, and says so in a comment.
* web: boot the demo before publishing ituser2026-09-12
| | | | | | | | | | | | | | | | | | v0.1.7 shipped a demo that could not start a session, and the site was live and broken because nothing between `make` and `rsync` ever ran the guest. The demo is the one artifact here whose build succeeding says nothing about it working: it is a cross-compiled riscv64 binary in an ext2 image, so the failures the rest of build-site would notice -- a compiler error, a missing file -- are not where its failures live. A guest that segfaults on startup links, packages and copies perfectly, and the page then loads, draws a terminal and sits there dead. web/demo/boot-check already boots that exact image in a native emulator and fails if init does not reach a composed screen. It took twenty seconds to find what the browser reported as one unexplained line, so it runs in the build now, between `make site` and the copy into the staging tree. NO_BOOT_CHECK=1 opts out; NO_DEMO=1 skips it with the rest of the demo.
* vendor: the wuffs calloc stub was hijacking the demo's allocatoruser2026-09-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The browser demo came up with `slosh: cannot start session main` and nothing else. The session server was segfaulting before its socket existed, on the first calloc() of the config parse -- because in that binary calloc() returned NULL, always. pkg/wuffs/src/main.zig exports weak `calloc`/`free` stubs whenever `!builtin.link_libc`, and callocStub returns null unconditionally. They are there because wuffs' generated `..._alloc()` helpers are the only code referencing libc's allocator, we never call them, and linker GC usually strips them; the stubs cover the case where it does not. The comment reasons that weak linkage means "any real definition wins". That holds for a shared libc and not for a static archive. Once the linker pulls this member in for another symbol, the weak calloc here *satisfies* the reference, so it never searches libc.a for musl's strong one -- and every allocation in the consuming program returns NULL while free becomes a no-op. Only the demo could hit it. It is the one build that must pass -Dsimd=false (highway emits RVV intrinsics that do not compile for rv64gc), GhosttyZig.zig sets `link_libc = if (cfg.simd) true else null`, and the guest links the result statically against musl. The host build links libc, never exported the stubs, and passed everything -- which is why the whole test suite was green over a demo that could not start a session. Patch 0002 adds `and builtin.output_mode == .Exe`. The stubs still do their job in a final link with no libc; a library stops interposing on the allocator of the binary that owns that choice. Worth sending upstream: the assumption is upstream's, not ours.
* sidebar: a status colour mixed from the theme, not the chrome's flat greyuser2026-09-10
| | | | | | | | | | | | | | | | | | tab_status_fg defaulted to the ambient grey on the argument that the slant already carried the distinction. True, and it looked it: the frame, the inactive labels, the pane count and the statuses were one colour, and the liveliest thing in the sidebar read as furniture. It is now 40% of the way from tab_count to tab_hover -- not the grey of the chrome round it, not the full accent it would shout in. Derived the same way rather than picked, so it lands in each theme's own family: a monochrome theme mixes two greys and stays monochrome, phosphor stays green, amber stays amber, and none of the seven in contrib needs a line adding to it. That the mix cannot invent a hue is the same argument the italic rests on, so both signals survive a colourless palette. tab_status_bg is still unset: a band is opt-in, and none is what a translucent terminal wants.
* sidebar: statuses wear italic instead of an indent, and can wear colouruser2026-09-10
| | | | | | | | | | | | | | | | | | | | | | | Three columns of indent is a fifth of a sixteen-column sidebar, spent saying 'this belongs to the row above'. The slant says it in none: a status now starts in the same column its tab's name starts in, and is set in italic. That also survives the monochrome themes in contrib, where a colour-only distinction would have had nothing to spend. tab_status_fg / tab_status_bg are there for themes that do have colour to spend. The fg follows tab_count when a theme does not name it -- the same declared/derived split apply_scrolled makes -- so the six themes in contrib get coherent status rows without a line added to any of them. The bg is unset by default, because a band is opt-in and 'none' is what a translucent terminal wants; the row is padded so a band that *is* set covers the row rather than the words on it. Two things fell out. The padding loop existed three times and is now pad_cells. And cb_color wrote #000000 for an unset colour, so quietly pinned attach_bg -- 'gray ink on the terminal's own background', says its comment -- to a black band. Unset colours are now written commented out, the same trick the states block uses for what it wants re-derived rather than pinned.
* sidebar: the new-tab button says what it is, given a row to say it onuser2026-09-10
| | | | | | | | | | | | | | | The mark went bare because in the strip it sits at the end of a row of tabs, where the company it keeps says what it does and columns are scarce. A sidebar row is neither: it is a row of its own with columns to spare, and a lone glyph on it reads as decoration -- the hint under the pointer cannot help, since you have to suspect a thing is a button before you hover it. So with the room it reads '+ new tab', in the word hint_for already gives this action rather than a second name for the same thing, and falls back to the bare mark when the sidebar is too narrow. The strip is untouched. The row fills and hits like a tab's, so hover and the drop-target fill cover the row rather than the word on it.
* sidebar: tab_bar_pad back to 0, now that the frame is the separationuser2026-09-10
| | | | | | | | | | | | | The row of air was standing in for a boundary the sidebar did not have. tab_bar_chrome gave it one, and a blank row inside the frame on top of that reads as a gap nobody asked for. The knob stays for a bare sidebar (tab_bar_chrome false), and for anyone who wants room at the top for a borderless window's own buttons. With the default at 0, Y0 in the tests is the unpadded first row and a pad of N moves the list exactly N rows down from it, which is what the pad test now says rather than carrying the default around in its arithmetic.
* sidebar: framed like a pane, and in compact mode framed *with* it ↵user2026-09-10
| | | | | | | | | | | | | | | | | | | | | | (tab_bar_chrome) A list of tabs sitting in bare columns beside a framed layout reads as text that wandered in from outside. So the sidebar gets a frame of its own -- and in compact mode not its own: its inner vertical lands on the tab area's ring column, and because the lines are strokes that union with whatever is already in the cell, the corners become tees without anyone computing a junction. The sidebar and the layout come out as one figure, which is the whole point. Classic mode gets a free-standing frame in the idle frame colour, rounded or square with everything else. The frame costs a column each side and a row top and bottom, taken from the sidebar's own budget -- and dropped entirely, before the list is, when the sidebar is too small to afford it: chrome must not displace what it is chrome for. The content box (cx/cw) now feeds the labels, the status rows, their hits and the truncation budget alike, so the frame cannot disagree with where the clicks land.
* docs: the tab bar's new shapes, written downuser2026-09-10
| | | | | | | | | | | | | A 'The tab bar' section on the panes page: the sidebar, its four knobs, the status rows under each tab, and the borrowed directory name for unnamed tabs. The config page's knob table learns the tab_bar_* keys. scripting.md and the driving-slosh skill stop underselling the status verb: it was documented back when the pane's own frame was the only place a status could appear, which made it decoration. With the sidebar it is how a pane reports progress to somebody working in another tab -- so say that, and say to keep it current and clear it when the work is done.
* tabs: an unnamed tab borrows the directory its focused pane is inuser2026-09-10
| | | | | | | | | | | | name, then purpose, then the basename of the focused pane's cwd, then the bare number. The kernel's answer (live_cwd, the same question save-workspace already asks), so a cd moves the label with you; home shows as ~. The focused pane rather than any consensus of the tab's panes: when they sit in different directories, the one you are typing into is what the tab is about, and a rule anyone can predict beats a cleverer one nobody can. Derived at draw time and never stored, so a real name wins the moment one exists, dumps and the tabs JSON stay unpolluted, and renaming starts from a genuinely empty name.
* sidebar: what each tab's panes are saying, said under the tab (tab_bar_status)user2026-09-10
| | | | | | | | | | | | | | | | | | Panes already announce a one-line status over OSC 5577, and it rendered in exactly two places -- both inside the pane's own frame, which is the one place you are not looking when you most want it. The sidebar has the vertical room the top strip never had: under each tab, up to tab_bar_status rows of what the panes inside said, in tree order, dim like the pane count. A dead pane's row says how it died instead (exit_words), the same substitution its own frame makes. The cap's last row is spent on an ellipsis rather than one more status pretending the list is complete, and a status cut to the sidebar's width ends in one too -- 'building 3' is a different fact than 'building 3/7'. Each row is a find: hit, the verb that already means 'go to this pane', so clicking a status jumps to the pane that said it, across tabs. Freshness costs nothing new: an OSC status marks the pane dirty, the same path the bell indicator rides out of background tabs.
* sidebar: a row of air on top (tab_bar_pad), indicators under the labels' insetuser2026-09-10
| | | | | | | | | | | | | | One blank row above the first tab by default: a list starting in the very corner reads as cramped, and in compact mode the row lines the first tab up with the pane content inside the ring rather than with the ring's top line. The pad is where the drawing starts, not a layout fact -- the panes' rect is identical at pad 0 and pad 5, so tuning it repaints without reflowing anyone. The bottom indicators (pane count, prefix badge) move in one cell to sit under the labels' own leading space; flush at the edge column they read as misaligned on the left and collide with the compact ring's corner on the right.
* tabs: the strip can stand on its side (tab_bar_side left/right)user2026-09-10
| | | | | | | | | | | | | | | | A sidebar lists one tab per row down the chosen edge, tab_bar_width columns wide; the panes trade the strip's row for the sidebar's columns in tab_area(), the one place that arithmetic lives. The per-tab cell drawing is extracted from draw_tab_strip (label, rename editor, bell, hover, drop states, hit) and shared, so every mouse verb -- click, double-click rename, drag to a tab, drop on + -- works in both shapes because the hits move with the cells. The strip's indicators (pane count, prefix badge) take the sidebar's bottom rows, reserved first under the same budgeting rule the strip applies from the right. The status line below keeps the full width. A terminal too narrow to give up the columns falls back to top, re-derived per frame, so a resize moves the bar without anyone storing state.
* build: a vendor bump rebuilds libghostty-vt, everywhere it is consumeduser2026-09-10
| | | | | | | | | | | | | | | | | | | | | Three stale-build landmines, all found by shipping v0.1.7 -- the first release where new code (GhosttySearch) actually needed the vendor bump of 2026-09-01: - $(VT_LIB) had no prerequisites, so an existing .a *was* the build: the macOS builder compiled pane.c against zig-out headers from before the bump. The vendor manifest is now the prerequisite; a re-vendor rewrites it, so a bump rebuilds the lib and its installed headers. - web/demo had the same guard and also never passed -Dversion-string: upstream's build.zig ran git describe in our repo, and on the tag checkout web/publish builds from, a slosh tag is a fatal version mismatch. Both fixed; both had hidden behind a stale build/vt. - The builder's zig-pkg (excluded from the sync, and left half-empty by a once-interrupted push) is an input, not a cache: it is now synced separately and additively, so a bump's new packages arrive and a partial local tree cannot maim the builder's copy.
* release: v0.1.7 paperwork -- formula and PKGBUILD pinneduser2026-09-10
|
* release: v0.1.7v0.1.7user2026-09-10
|
* compact: the minimised bar sits below a shared line, not on the borderuser2026-09-09
| | | | | | | | | | | | | A compact pane owns no border cells -- its bottom border is the shared line one cell outside its rect. With panes minimised, that cell was the bar's top row: the chips sat directly on the bottom panes' content, reading as frames cut into the pane. Classic panes carry their own border row and never collided. The layout now reserves one extra row above the bar in compact, and draw_compact_lines strokes the boundary there: it joins the outer ring with real junctions, a divider from above ends into it, and a focused bottom pane re-strokes its stretch like any shared line.
* pane: shift consumed by the character is consumed, tell the encoderuser2026-09-09
| | | | | | | | | | | | | | | 935fb7e9 taught the legacy decoder to read the shift out of shifted punctuation, so / and ? could be two keys. But pane_send_key never set the consumed mods on the re-encoded event, and libghostty's kitty path only passes text through as text when no unconsumed modifier remains: a kitty-protocol pane (nvim) received : as CSI 59;2u -- shift+semicolon, no text -- and the command line never opened. :wq! was unreachable. Mark shift consumed when the event carries text, shift is held, and the text is not the unshifted character -- the same call a real terminal makes. Text the shift produced goes through as text; chords whose shift is real (shift+up, ctrl+shift+a, back-tab) keep their escapes, and a legacy pane sees no change.