| Commit message (Collapse) | Author | Age |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pane image is re-emitted to the client as decoded RGBA, which for one
screenshot is ~4MB of base64 in a single indivisible message -- and
conn_send silently dropped anything that would overflow the 4MB outbox,
while push_graphics ignored the refusal and the model advanced anyway.
Everything stateful in that dropped frame was lost for good: the image
was marked sent (so its placements drew nothing, forever), and the
deletions rode out with it (so a placement the client had was never
revoked). Reported as screenshots in a recovered pi transcript parked
at a fixed spot on the screen, immune to scrolling and pane swaps --
those were placements whose deletion left with a dropped frame. The
1000x800 one could never be delivered at all: its transmission alone
was bigger than the cap.
So the frame is now transactional. Deletions queue and are repeated
every flush until gfx_commit() confirms the client heard them;
transmissions recorded in a frame are unwound by an undelivered commit
so they are sent again. A flush nobody commits counts as undelivered at
the next gfx_begin(), which is what the `graphics` control command is:
bytes rendered for a CLI, not the client. The server reports the
verdict and keeps the paint pending until it lands.
The outbox cap now bounds the backlog rather than backlog-plus-message,
so one big transmission on a drained queue always fits -- and that
surfaced a second way to lose: a queued image legitimately holds the
backlog over the cap until the client swallows it, and push_frame read
that as a dead client and closed the connection. The session
auto-detached every time a screenshot crossed the wire. A full outbox
is a client that is *behind*, not gone: push_frame now marks the screen
for a full repaint (the dropped diff means the client's cells are
unknown) and retries as POLLOUT drains the queue; a client that truly
left still shows up as a write error there.
The headless suite pins the owed-until-committed contract; the new
tests/live_graphics.py drives the real socket with a
bigger-than-the-cap image and a reader that stalls, and would have
caught both bugs: the flood must not detach, the transmission must
arrive whole, and a placement scrolled away must be deleted -- then
come back on scroll without a second copy of the pixels.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The functions the last two commits fixed are only interesting on the
platform the suite does not run on, which is how they came to be wrong
in the first place. So the Windows cases are asserted under #ifdef and
the POSIX ones assert the opposite: that a drive letter is an ordinary
name here, that a backslash is an ordinary character, that neither rule
leaked across.
That second half is the one worth having. Both fixes are guarded, and a
guard is exactly the kind of thing a later edit removes without meaning
to -- `\` splitting a POSIX path would invent a directory out of a file
called `a\b`, and nothing else in the suite would notice.
Pure functions, so it joins the C unit tests rather than driving a
session. Run on the Windows box as well, cross-compiled: 28 checks
there, 20 here, the difference being the cases each platform has.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
On Darwin a pty master has no line discipline until its slave has been
opened: every TIOCSWINSZ on the master until then fails with ENOTTY and
the size is dropped without a word. A pane is born 1x1 and told how big
it really is by the layout an instant after the fork -- and with the
child doing the open(), that message raced the child and usually lost.
Nothing ever sent it again, so the program in the pane spent its whole
life believing it had one row and one column. `C-a e` hit it every
time, because the editor's pane is always brand new: nvim drew its
screen into two rows in the corner of a 94x15 pane.
The old code knew half of this -- the child set the winsize on the
slave it had just opened, which is what kept a fresh pane from being
0x0 -- but that only fixed the *spawn* size. Every resize after it
still went to a master that might not have a slave yet.
So open the slave here, before the fork, and let the child inherit it.
The fork copies the fd table, so the slave is open from the child's
first instruction and every resize the parent sends afterwards lands;
BSD's forkpty() does the same thing for the same reason. The winsize is
set on that slave before the fork, which orders it ahead of anything
the parent sends later, and the child no longer sets it at all: by the
time the child ran, the size it was spawned with could already be
stale, and writing it back would undo the very resize this is about.
test_pty_size.py asks the pane's own program what size it thinks it is
-- for the first pane, for a pane from a split, and after a resize --
and compares it to the pane. It printed "1 1" against a 74x18 pane
before this.
live_m0.py asked `tput cols`, which needs a terminfo entry for
xterm-ghostty and therefore fails on most macs for a reason that has
nothing to do with resizing. `stty size` asks the tty itself, which is
the thing under test.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Section 5 is where unix keeps its file formats, and slosh has two
documents sharing one syntax -- the configuration and the layouts -- so
they share a page the way they share a parser. docs/slosh.5 covers the
KDL subset, where the file lives and how reload refuses a broken one,
include and its precedence, every top-level setting grouped the way
config.md groups them, the theme block, the shader vocabulary with the
expression language summarised, the states and their ranking, the keys
block including direct bindings, and the layout schema (tab, pane, and
their properties).
Held to the source like slosh.1 before it: every name in config.c's
KNOWN_TOP -- the list the loader refuses unknown settings against,
itself held to the loader by test_config.py -- must appear in the page,
so a knob cannot be added without this page hearing about it. The pair
must cross-reference each other, and both must be pure ASCII and lint
clean under groff -ww.
slosh(1) grows a SHADERS section -- the one feature a synopsis reader
would never guess a multiplexer had -- and its SEE ALSO now names
slosh(5) and nothing else: the other multiplexers can advertise
themselves.
Install puts the page under share/man/man5, the release tarballs carry
both pages, and the Homebrew formula installs what the commit it is
building has, guarded as before.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The distributables were a bare binary and a directory of Markdown, which
is fine until somebody types 'man slosh' -- the unix-traditional place
to ask. docs/slosh.1 is hand-written man(7), pure ASCII roff, no
toolchain: the synopsis, the options, the session commands, the default
keys, the mouse (including the new drag-past-an-edge scrolling), where
the configuration lives, what SLOSH_* means in a pane's environment,
files, exit status. The prose docs stay the long form; the page says so
and points at them.
'make install' puts it under PREFIX/share/man/man1 (and uninstall takes
it away), the release tarballs carry it beside the LICENSE -- a tarball
is what a packager starts from -- and the Homebrew formula installs it
when the commit it is building has one, guarded because the formula must
be able to build commits older than the page.
Prose about a program drifts unless something holds them together, so
test_docs.py holds the page to the source the way it already holds the
site to docs/nav: every option main.c's parser compares against and
every word command must appear in the page, every SLOSH_* variable the
code reads or sets must be documented, the roff must be pure ASCII (it
travels through implementations that never heard of preconv), and where
groff exists the page must lint clean at CHECKSTYLE=3 with -ww. Adding
a flag without documenting it is now a failing test, which is the only
kind of documentation policy that survives contact with a deadline.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The h-centered check raced the assembly it was measuring. landed_row()
answered as soon as one occurrence of the opening run had a block glyph
at its start -- but the logo's first line contains that run three times,
assembly is staggered, and under a parallel suite's load the middle
section can finish whole checks before the leftmost. The test then
measured a middle occurrence's x (24) and called the logo off-centre.
The row is 'landed' when all three occurrences are present -- its final
form -- and the first block glyph is still required to sit where the
match begins, so a particle passing to the left keeps us waiting rather
than shifting the answer.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"reattaching repaints the whole screen" greps the client's byte stream
for the text a pane was holding, and failed about half the time.
The splash assembles from particles scattered over the entire screen, so
for the first few hundred milliseconds of an attach any cell can be
holding a fragment of the logo. The full repaint that a reattach
triggers goes out during exactly that window, so it can carry
│marker-alp▄a
with a particle where the "h" should be. What restores that cell when
the greeting ends is a one-cell diff, cursor-addressed, so the string is
never contiguous in the stream again and the substring check fails.
Nothing about slosh is wrong here -- the model has the text throughout,
and `snapshot text` shows it -- so the fix is to stop asking this file to
assert about the bytes of an animation it is not testing. The session
under test gets a config with splash_ms 0. The greeting has test_splash.
5/5 where it was roughly half.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
A split, the config editor or a new float while zoomed used to land the
new pane behind the pane filling the tab -- which looks exactly like the
verb having failed. Whoever asked for a new pane asked to see it, so the
zoom yields, in place_beside where every such arrival funnels through;
the move-pane unzoom was this rule as a special case and folds in.
Minimising the zoomed pane ends the zoom too, or the layout would solo a
pane the strip now holds.
|
| |
|
|
|
|
|
|
|
| |
A new client displaces the current display, so `slosh` typed into a pane
of "main" displaced the very client showing that pane -- you watched your
own session detach, from inside it. SLOSH_SESSION names the pane's own
session; that one name is refused with the way out spelled in the message.
Any other name is deliberate nesting, and cmd/ls are untouched: they are
how a program in a pane is supposed to reach its own session.
|
| |
|
|
|
|
|
|
| |
A tiled pane sits on a cleared screen, so unpainted padding looked fine by
accident; a float sits on whatever was composited under it, and every
unpainted cell let that show through. draw_frame now fills the ring between
border and contents with exactly what screen_clear leaves, and a suspended
pane's never-composed content rect gets the same treatment.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
Plain ASCII that says what it means, over a dot that had to be noticed.
The mark was always a short string rather than a glyph (the emoji-width
argument in config.kdl stands); the default just stops being shy.
|
| |
|
|
|
|
|
|
| |
Four rows deep at 60 a row instead of three at 45: the edge row is nearly
gone (#0e0e0e from white) and the melt reads as "there is more this way"
rather than as a rendering artefact. The quarter-viewport cap, the
(above > 0) gate and everything else about the derivation stand; a config
that writes its own scrolled chain is untouched, as ever.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A dumped session quietly re-tiled its floats, which was the one data-loss
gap left in D22. dump-layout now records `floating=true x= y= w= h=` -- the
*wanted* rect, because intent is what survives a session and the next
screen's layout clamps it like any frame -- layout files declare the same
(linted: floating is a bool, x/y/w/h mean nothing without it), and a file
whose every pane floats self-heals through ensure_tiled: the top float
lands, the same rule closing the last tiled pane follows, no lint required.
save-workspace inherits all of it from the shared dumper.
`float` over the API takes x/y/w/h and then *places* rather than toggles --
a placement is a statement about where, not about whether -- floating the
pane first when it is tiled, re-placing when it already floats, with
omitted fields meaning "keep". The intent is stored exactly as asked: the
write-back rule is for hands, whose drags need the clamp to agree with the
eye; a script means what it typed.
And kitty graphics respect the stack. The cell compositor gets occlusion
free from paint order, but placements are sent after the diff and would
paint an image straight over a float covering it. Each pane's placements
are now clipped by the float rects above it -- every float for a tiled
pane, the higher-raised for a float -- cropping what one clean edge can
express (the pane-edge source-rectangle arithmetic aimed at another
clipper, including from the top and left, where the source origin moves and
the sub-cell offset is consumed) and suppressing what it cannot: a strip
across the middle or a corner overlap leaves a shape one placement cannot
say, and the image returns whole when the float moves. No z= needed --
clipping is the z-order, since nothing is ever allowed to overlap.
D22's "still owed" paragraph is paid and rewritten; M10 is fully checked.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
lib-vt tracks a hyperlink per cell and slosh dropped every one of them: the
client's terminal can only offer a link it was sent, so `ls --hyperlink`,
gcc diagnostics and friends silently lost theirs inside the session.
The link rides beside the cell -- cell_t is the shader ABI and a link is
not a colour -- as a plane of ids into an interned URI table, append-only
so an id in prev_link still means the same URI next frame (which is what
lets the diff compare ids instead of strings), and capped so a hostile pane
cannot grow it forever. URIs with control bytes are refused outright: they
are emitted verbatim inside an OSC, and a control byte in one is a way to
write escape sequences into the client's terminal. The diff wraps emitted
runs in OSC 8, closes what it opens before the frame ends, and treats a
link-only change as a change. Chrome painted over a link takes the link
with it, so a frame, modal or float across a URL is never clickable with
the pane's link; links survive scrollback with the text that carries them.
Plain-text URLs stay the outer terminal's own matcher, with the catch
documented in panes.md: under mouse capture Ghostty only offers links with
shift added to the gesture (shift+cmd hover/click), shift being its
capture escape. Wrapped URLs can never be regex-matched through a
multiplexer -- rows repaint as hard lines -- which is exactly what OSC 8
links, passing through whole, are for.
`snapshot format:"bytes"` returns the emitter's own output for a frame (a
second call is the delta): the model and the bytes are two different
things, and this codebase already paid once for asserting on one while the
other was wrong.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
Targets: kdl, jsonval, expr, proto, osc5577, input — each compiled
standalone against its own .c with ASan + UBSan, no vendor deps.
Includes seed corpora from shipped configs/themes/chrome, real control
API lines, terminal escape sequences, and regression inputs from the
bugs already fixed. standalone_main.c replays files without the fuzzer
runtime for debugging and CI.
Top-level: make fuzz (run all, TIME=15 default), make fuzz-corpus
(replay seeds only, fast CI gate).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The old name argued that the extension should say how to read the
file. The argument lost to a collision: zellij speaks the same KDL
syntax under that same extension with a different schema, and to
anyone arriving from there .kdl said *whose* file it is -- naming the
wrong owner is worse than not naming the parser. So a layout is
x.layout, a project's is slosh.layout, the name says what the file is
and --check says how it was read. (It is still KDL underneath; point
your editor's highlighting at *.layout.)
Nobody's checked-in workspace vanishes on upgrade: the project scan
still finds slosh.layout.kdl, never writes it, and where both
spellings exist the new one wins, because two files disagreeing needs
one answer. The unparseable-file heuristic in --check learned the new
suffix -- it matched '.layout.' with a trailing dot, which the new
name no longer has.
The webdemo overlay moves with it (demo.layout), rebuilt and
boot-checked.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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().
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]━╯
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The bumpy one, again. Every `.py` outside `vendor/` plus the three `contrib/`
scripts that are Python with no extension: 60 files, 6,714 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.
It is a bigger reformat than the C was, 60% of the lines against 17%, and for a
reason worth writing down rather than tuning away: ruff and black break a call that
does not fit onto one argument per line, where this code aligned the continuation
under the opening paren. The test suite is thousands of `check("some prose", cond,
detail)` calls, so nearly every one of them changed shape and the suite grew 3,308
lines. Line length was the one lever that moved it -- 120 columns would have been a
40% reformat and no growth at all -- and it was left at 88, because picking an
unusual width to dodge a one-time diff is paying forever to avoid paying once.
Import sorting came with it. Two files had `import os, pty, uuid, select, struct,
sys, termios, time, fcntl, signal` on one line and now have ten.
Verified after, not before, because a formatter that alters behaviour would be
worse than none:
- `make retest`, 1801 checks green -- and this reformats the suite that does the
checking, including `harness.py`, which every one of those files imports.
- `make test-live`, the tty tests the fast suite skips. They matter here more than
usual: `live_input.py` and `live_m0.py` are two of the files whose comma-imports
were split, and nothing in the fast suite would have noticed if that broke them.
- All 60 files still compile, `make fmt` twice in a row changes nothing the second
time, and `ruff check --select I` has nothing left to say.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
```
│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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Select a line too long for the pane and the clipboard came back with a `\n` in
the middle of it, at whatever column the pane happened to end. Paste that into a
shell and it runs half a path.
`pane_selection_text` set `trim` and left `unwrap` at whatever a zeroed struct
gives, which is false. lib-vt's header names the exact trio the clipboard wants --
plain output with `unwrap` and `trim` both true, matching Ghostty's own
`Screen.selectionString()` -- and we had two of the three. A row boundary in the
grid is not a newline in the text: a long line occupies two rows and is still one
line, and where it breaks is a fact about how wide the pane is rather than about
what was copied.
Hard newlines are untouched, which is the half a fix for this could easily have
broken: lib-vt joins only the rows that are continuations, so three separate lines
still come back as three lines. Both directions are now asserted, because "copy
joins everything" would be a worse bug than the one being fixed and would look
identical from the inside.
`emit` is now set explicitly as well, to `GHOSTTY_FORMATTER_FORMAT_PLAIN`. It is
already zero, so this changes nothing today -- but this is the API where a zeroed
field means "whatever the enum happens to start with" (DESIGN.md's note on
`ghostty_terminal_set`), and a silently defaulted field is how the wrap flag came
to be missing. There is one call site, checked.
Verified: three checks in `tests/test_clipboard.py` -- a 50-character line printed
into a 40-column pane, dragged across the row boundary, arrives as the string that
was printed with no newline in it; three short lines keep their two newlines; and a
double-click on a word in a wrapped line brings back no newline either. 46 checks in
that file, 1795 overall, all green.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`server.c` set `SL0PPTY_SESSION` for its panes and carried a comment saying the
value was "deliberately unset under `--script`". Nothing unset it. A value never
set is a value inherited, so a script driver started from inside a pane handed its
own panes the *outer* session's name -- and the recipe the skill documents,
`-s ${SL0PPTY_SESSION:-main} cmd`, then sent its commands to a session it was not
in. Not a fallback to a guess: a confident wrong answer, because the name it read
was a real session that really answers.
Both variables now go through one `session_env()`, called by the server with its
name and by the headless driver with NULL, because the two modes have to agree
about what a pane is told and a comment in one of them is not agreement.
`SL0PPTY_BIN` is set there too rather than inherited, so it names the binary that
actually made the pane instead of one that made some pane somewhere -- which is
the whole reason that variable exists.
Found while explaining a stale pane title, from a check in `test_skill.py` that
failed under `make test` and passed on its own. It was not flaky: the suite runs
from inside a pane, where these are set, and the tool I ran it from by hand had an
environment where they were not. A test whose answer depends on where you run it
looks exactly like a race.
So it now passes the lie in deliberately: the session under test is started with
`SL0PPTY_SESSION=somebody-elses-session`, which makes the difference between
"never set" and "cleared" visible from any shell. The mirror check is next to it --
a real session tells its panes its *own* name, even when started from inside
another one -- so the obvious wrong fix, unsetting it in both modes, fails. Both
were verified by making both wrong versions and watching each test bite.
The two title checks now wait for the line instead of sampling after 120ms. That
was not the bug (a shell prints in about 12ms here, even under disk churn) and it
is one assumption fewer.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.agents/skills/driving-sl0ppty/SKILL.md`. The convention is
`.agents/skills/<name>/SKILL.md` with YAML frontmatter -- already in this tree
under `vendor/libghostty-vt`, so it is not an invention -- and it means an agent
working in a checkout picks the skill up without being told, while a directory per
skill leaves room for adjuncts later. `contrib/README.md` and
`docs/scripting.md` say where it is and how to use it elsewhere: copy or symlink
the directory into wherever your agent keeps skills.
**Writing it turned up the reason there was no skill worth writing.** A pane is
told `SL0PPTY=1` and nothing else, so a program inside one knows it is in a
session and cannot find out *which* -- `ls` lists them all and none of them is
labelled "the one you are in". So the whole most valuable case, an agent driving
the session it is running in, was guesswork. The server now sets
`SL0PPTY_SESSION` and `SL0PPTY_BIN` in its own environment, which every pane
inherits through fork: one place, no signatures threaded, and unset under
`--script`, which has no socket to name. `SL0PPTY_BIN` is read from
`/proc/self/exe` rather than `argv[0]`, and exists because the failure it prevents
is the one I hit writing the examples -- a session started from a build tree, and
`sl0ppty` not on the pane's `PATH`, so the skill's own commands did not run.
What the skill actually teaches, none of which `docs/scripting.md` says because a
reference is not allowed to have opinions:
- Work belongs in a pane that was **given** a command, not typed into somebody's
shell. Then `alive` and `exit_code` off `panes` are how you wait, and the output
survives the program (D14) so it is still there to read.
- There is **no wait primitive over the socket**: `settle` is headless-only, and
`deadline` is about repaints and says nothing about program output. Poll.
- `purpose` is the handle. Titles change underneath you; a declared purpose cannot
be relabelled by the program in the pane, which is what makes it safe to address.
- `snapshot` is the whole composited session, and only what is on screen. Cut a
pane out with its `content_*` rect; do not expect scrollback.
- **The echoed command line matches your own marker.** `echo DONE` puts `DONE` on
screen twice. Count, or use a marker you did not send.
- Write `\e`, never `\033`: the unescaper takes `\0` first, so `\033` arrives as
NUL followed by `33` and nothing complains. That bug was live in this repo's own
test suite until last week.
- A program in a pane can draw its own status and real buttons with OSC 5577 and
read the click off its stdin, which is how it asks a question instead of printing
a prompt and hoping.
- Close what you made; do not `quit` a session you did not start.
`tests/test_skill.py` is the part that keeps it true. A skill is documentation an
agent acts on without a human reading it first, so a stale one is worse than a
missing one: a wrong verb is a failed tool call somebody else has to debug. Every
verb the file names is sent to a live session and must not answer `unknown cmd`;
every `panes` field its central recipe polls must exist; the environment it
promises is read out of a real pane; the recipe itself is run end to end and must
report exit status 3 with its output still on screen. It also greps the file for
paths out of a home directory, because "plug and play" is a property somebody has
to keep.
That test found `close-pane` in the draft, which is the *keybinding* action name --
the verb is `close`. Exactly the class of error the test exists for, found before
anybody's agent ran it.
Verified: 1795 checks, all green, 43 of them in `tests/test_skill.py`. Then the
skill's recipe run verbatim against a real session -- tagged pane, polled to
`alive:false`, `exit status: 3`, `compiling`/`built`/`exited: status 3` still
readable -- and from inside a pane, `session=agentdemo bin=/…/sl0ppty` and a
`building 3/7` status drawn in its own frame.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`test_a_config_written_after_the_session_started_is_noticed` starts a session
whose config directory does not exist, then writes a config into it. The session
is what *creates* that directory -- the whole point of the test is that a file
appearing later gets noticed -- and `sl0ppty -s` returning means the session was
asked for, not that it has got that far. So the write could land first, and did:
FileNotFoundError: /tmp/sl0ppty-home-rl_q8peu/.config/sl0ppty/config.kdl
Once in about eight full-suite runs, never on its own, never under load I could
synthesise -- which is why it took ten runs in a row to catch and why it has been
sitting there since the watcher work. Nothing to do with what is around it in
this branch; found because I was hunting a flake I had caused elsewhere and this
one answered instead.
Fixed by waiting for the directory, which turns the thing the test *depended* on
into something it *states*: "the session created the directory it needs to
watch". The include test's startup check is polled for the same reason, since it
too read a screen the session had not necessarily drawn yet.
Verified the only way this kind of thing can be: ten consecutive clean runs of
the whole suite, and the file's own eleven checks green in isolation.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Both preset suites ask "does this preset do anything", and both were answering
it by looking a fixed number of times and comparing. `test_shader_presets.py`
looked exactly once; `test_chrome_presets.py` looked five times, 50ms apart.
They passed for a reason that stopped being true. Until `a5114a9` a pass at zero
strength still materialised every cell it touched -- resolving the terminal's own
colours into ours -- so *any* chain, at any phase, changed the cells. Comparing
against unshaded could not fail. Now that an identity pass leaves a cell alone,
the question is honest, and the answer is genuinely "nothing" at some phases:
`motion-typewriter` dims the rows below a line walking down the pane, and one
frame in eighteen that line is past the last row.
So both poll for the change with a deadline instead, which is what the harness
says to do about anything observable: the deadline bounds failure only, and a
preset that paints is seen the moment it paints. The chrome one keeps a minimum
window as well, because its second assertion is "the contents did *not* move",
and nothing needs a while to be sure of.
Found by the suite going red under its own parallel load while every file passed
on its own -- which is the useful failure, since it means the flake was real and
not the machine.
|