<feed xmlns='http://www.w3.org/2005/Atom'>
<title>sl0ppij.git/zellij-utils/src/kdl/mod.rs, branch main</title>
<subtitle>a sl0p.foo repository</subtitle>
<id>https://git.sl0p.foo/sl0ppij.git/atom/zellij-utils/src/kdl/mod.rs?h=main</id>
<link rel='self' href='https://git.sl0p.foo/sl0ppij.git/atom/zellij-utils/src/kdl/mod.rs?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/'/>
<updated>2026-08-12T16:44:24Z</updated>
<entry>
<title>sl0p: --purpose flag and tab-level purpose</title>
<updated>2026-08-12T16:44:24Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T16:44:24Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=2c67579398835d51e625085ce3b22c22bc8f0cba'/>
<id>urn:sha1:2c67579398835d51e625085ce3b22c22bc8f0cba</id>
<content type='text'>
Completes the two pieces deferred from the first workspace-hints pass.

`zellij run --purpose X` / `zellij action new-pane --purpose X` tag a pane at
spawn time, which is what an agent wants for panes it creates itself; the OSC
wrapper still works for programs that declare their own role. Not supported with
--in-place, which replaces another pane rather than being a pane of its own: clap
rejects the combination rather than accepting a flag that would do nothing.

`tab name="work" purpose="agent-work"` gives a whole tab a role, reported by
`list-tabs --json`. It rides the tab's ROOT layout node, reusing the pane field
because a tab's root is the tab container and never itself a pane -- asserted by a
test for the tightest case (a tab holding exactly one pane), since a leak there
would silently mislabel a pane.

The interesting part is why the flag first shipped broken. There are TWO protobuf
boundaries -- the plugin API and the client-&gt;server IPC contract -- and a CLI flag
must cross the second one. Defaulting the field in the conversion (what "no
protoc" usually forces) compiles cleanly and then silently drops the value in
transit, so --purpose parsed, printed a pane id, and did nothing. Fixed by adding
the field to both contracts by hand, in the .proto AND the committed generated
prost, kept in lockstep so a future regeneration reproduces it: protoc is only
needed to regenerate, not to build. Both boundaries now have a round-trip test,
and the IPC one was confirmed to fail when the wire read is removed.

Sanitising moved to Grid::set_authoritative_purpose, the single choke point every
out-of-band source passes through, after the CLI path was observed delivering
`bad purpose; rm -rf /` intact into list-panes --json.
</content>
</entry>
<entry>
<title>sl0p: add an indexed style for status bar buttons</title>
<updated>2026-08-12T14:15:19Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:15:19Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=c3764fca00c1d91c06612a33162b2354e9d42be0'/>
<id>urn:sha1:c3764fca00c1d91c06612a33162b2354e9d42be0</id>
<content type='text'>
`status_bar_button_style "indexed"` renders `1:Approve 2:Reject` instead of
`[Approve] [Reject]`, so the bar doubles as a key legend for a harness that binds
alt+1..9 to the same actions.

A config option rather than a protocol field: whether the numbers mean anything
depends on what the USER has bound, not on what the program knows. It needs no
wire change and so no version bump, and the click report still carries the id --
never the index -- so a program is unaffected by which style is in force.

The style is threaded into layout_status_bar, the one function the renderer draws
from and the hit-test resolves against. That is what makes this safe: changing a
button's rendered width necessarily changes its clickable span too, verified by a
test asserting span width equals the drawn width and that every drawn column
resolves back, for both styles.

Also fixes the web client's Style construction to read the option from config
rather than defaulting it -- my first pass claimed that code had no pane_frames
config available, which was wrong; it uses it two lines further down.
</content>
</entry>
<entry>
<title>sl0p: separate the hover resize hint from the hover highlight</title>
<updated>2026-08-12T12:09:21Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T12:09:21Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=ea500ea70aa77da57870e8272bab593887819b0b'/>
<id>urn:sha1:ea500ea70aa77da57870e8272bab593887819b0b</id>
<content type='text'>
Upstream's mouse_hover_effects governs two unrelated things: the frame highlight
on hover, and the "Ctrl &lt;MouseScroll&gt; or &lt;drag borders&gt; to resize" hint. Its own
comment in default.kdl admits as much. So silencing a beginner reminder you have
outgrown also costs you a highlight that stays useful indefinitely.

mouse_hover_help_text (default true) gates only the hint. One line in
execute_send_to_terminal; the highlight is set in execute_update_hover and is
untouched.

Config-file only, deliberately. The option has no protobuf slot, so it does not
cross the client/server IPC contract -- the roundtrip test asserts exactly that,
with the fixture set to None and a comment explaining why. It still works from
the config file because the server parses the file itself and Options::merge
keeps the file value when the client sends None; a CLI flag would be silently
ignored.
</content>
</entry>
<entry>
<title>sl0p: add close_if_focused to LaunchOrFocusPlugin</title>
<updated>2026-08-12T11:16:00Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T11:16:00Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=733ac78ca77cc61b52bb627e4c02eafa80282944'/>
<id>urn:sha1:733ac78ca77cc61b52bb627e4c02eafa80282944</id>
<content type='text'>
Upstream's action focuses an already-running plugin, so a chord bound to it can
only ever open one -- there is no way to dismiss with the same key.

This cannot be fixed in a plugin. Keybindings are resolved into actions in
route.rs before a key reaches a pane, so the launching chord never arrives at
the plugin. intercept_key_presses does not help either: the intercept lives in
screen.rs and only sees keys that already became Action::Write, i.e. keys
destined for a pane. Confirmed empirically -- an intercepting build received
Ctrl-u but never Ctrl-;.

Closes only when the plugin is the focused pane in the client's ACTIVE tab, so
the chord cannot close whatever pane the user happens to be on. A plugin that
exists but is hidden behind a floating-layer toggle, or is focused in a
different tab, is revealed or brought over as before.

Defaults to false, so every existing binding is unaffected. Config-only, with no
protobuf representation: defaulted at both boundaries and dropped when an action
crosses the plugin API, so no prost regeneration is needed.

The 24 snapshot lines are the new field appearing in serialised keybind dumps.
</content>
</entry>
<entry>
<title>sl0p: add a minimal style for the stacked-pane picker</title>
<updated>2026-08-11T18:08:23Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-11T18:08:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=3418e8218d91d916b3b2a45ac81d3032c27575c6'/>
<id>urn:sha1:3418e8218d91d916b3b2a45ac81d3032c27575c6</id>
<content type='text'>
The boxed entries read as clutter: every entry bracketed in vertical bars, and
the active one marked by a "&gt; " that sits outside its own cell.

`stack_list_style "minimal"` drops both. The active entry is shown by
inverting it instead, which needs no glyph and no column, and the padding is
moved inside the styled run so the inverted block covers the whole cell rather
than leaving unstyled gaps at its edges. Four fewer columns of decoration per
entry, so more fit per row in grid mode.

Uses ribbon_selected rather than text_selected. Themes give ribbon_selected
real contrast because it marks the selected item elsewhere in zellij's UI --
gruber yellow here, white-on-blue in the TempleOS theme -- whereas
text_selected's background is 40,40,40 against a 24,24,24 terminal and read as
essentially no highlight when tried.

Decoration width comes from the style itself, so Tab's column arithmetic and
the renderer cannot disagree about how wide an entry is. Default stays boxed,
so nothing changes for anyone who does not ask.
</content>
</entry>
<entry>
<title>sl0p: optionally render the stacked-pane picker as a grid</title>
<updated>2026-08-11T14:33:46Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-11T14:33:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=4e001669f5a9dcc883f58bf0c3710c7460ae93cc'/>
<id>urn:sha1:4e001669f5a9dcc883f58bf0c3710c7460ae93cc</id>
<content type='text'>
`stack_list_layout "grid"` packs picker entries several per row, which turns
stack_list_max_rows from a count into a space budget: eight panes in a wide
terminal now fit on one row instead of spending three and hiding five.

The entry renderer already drew each entry as a single chunk at its own x/y,
sized by its geom and centring its own label, so a grid needed no rendering
changes at all -- only the loop assigning every entry the same x and the full
width. Column count is derived from the widest label rather than configured, so
it adapts to the titles; one column is the floor and reproduces the previous
full-width rows byte for byte.

The row -&gt; member mapping now has a column axis, and it had already caused one
wrong-pane bug when two consumers disagreed. So the layout is computed once,
by one function, into explicit cells; the renderer, the reserved-row budget and
the mouse hit-test all read those same cells rather than each doing their own
arithmetic. Windowing is by row, so a grid scrolls a whole row at a time and
degrades to the old per-member window when there is one column.

Cells are separated by a two-column gap: without it the focused entry's "&gt; "
marker sits flush against the previous cell and reads as belonging to it.
</content>
</entry>
<entry>
<title>sl0p: cap how many rows the stacked-pane picker takes</title>
<updated>2026-08-11T12:14:53Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-11T12:14:53Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=9441f1b177c1e08f4ceae76ef70d444103d15efa'/>
<id>urn:sha1:9441f1b177c1e08f4ceae76ef70d444103d15efa</id>
<content type='text'>
A stack reserves one row per member plus a padding row, so a deep stack spends
most of the screen listing itself. `stack_list_max_rows` caps that; the list
windows around the focused member and reports the rest as "+N more".

The indicator goes in the padding row the list already reserves, so it costs
no extra row and never displaces a real entry. Entries keep their real rank
rather than renumbering from 1, otherwise the numbering stops meaning anything
once the window has scrolled. The window slides on ordinary focus movement,
which is also why this needs no new keybinding -- Action is a protobuf enum
and the prost assets cannot be regenerated here (no protoc).

A fixed cap rather than hover-to-expand on purpose: reserved_top_rows feeds
pane geometry, so growing the list on hover would resize the pane's PTY and
reflow whatever is running in it on every mouse move. A real expand/collapse
has to be an overlay that reserves nothing, which is a much larger change to
the render path.

reserved_top_rows and the renderer both go through stack_list_window(); if
they disagreed the list would draw into rows the geometry never reserved, i.e.
over the pane beneath it.
</content>
</entry>
<entry>
<title>sl0p: collapse to a swap layout when the terminal gets narrow</title>
<updated>2026-08-10T22:12:10Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-10T22:12:10Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3'/>
<id>urn:sha1:393748bb2fb0ad741f2d51cfe0f8db75cf2ad7c3</id>
<content type='text'>
Splitting a window into thirds is great until the window is 70 columns wide
and each pane gets 23. `responsive_min_cols` collapses the tab into the swap
layout named by `responsive_layout` when it gets narrower than that, and
restores the previously active layout when it grows back.

Selects a swap layout BY NAME rather than adding a size-aware
LayoutConstraint: that type crosses both the plugin protobuf API and the
client/server contract, and the committed prost assets cannot be regenerated
here (no protoc).

Restoring uses a 10-column hysteresis band. Without a dead band, dragging a
window edge that lands exactly on the threshold flips the layout on every
motion event, and every flip resizes every PTY in the tab.

"Did the user rearrange panes by hand" is judged from the damaged flag as
sampled BEFORE each resize, not the live one: resize_whole_tab's own
auto-layout pass sets that flag itself and clears it again only if it found a
layout that fits, so on a terminal too narrow for any of them it latches true
although nothing was rearranged. And once collapsed, restore stays reachable
regardless of the flag, so damage occurring while collapsed cannot strand the
tab in the narrow layout.
</content>
</entry>
<entry>
<title>sl0p: optionally hide the 'Pane #N' placeholder title</title>
<updated>2026-08-08T20:47:16Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-08T20:47:16Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=9aa323fe6b9d15360ba54a1c73a216228664a289'/>
<id>urn:sha1:9aa323fe6b9d15360ba54a1c73a216228664a289</id>
<content type='text'>
`ui { pane_frames { hide_default_pane_names true } }`, default false.

A pane now shows no title at all until something actually names it: the user
renaming it (including by double-clicking the title row) or the running
program setting a title. Upstream always shows an auto-generated 'Pane #N'.

Implemented by tracking whether pane_title is still the constructor's
placeholder, NOT by matching the string: a program that legitimately titles
itself 'Pane #3' must not be mistaken for an unnamed pane. set_title clears
the flag, since something then chose that title.

current_title reads the option at render time rather than baking an empty
title in at construction, so it hot-reloads and turning the option back off
restores the placeholder on existing panes.

Display precedence is unchanged and now explicit in the code:
    user-given name &gt; title set by the running program &gt; placeholder

render_terminal_title already fell back to an empty string rather than the
placeholder, so the terminal WINDOW title is unaffected.

Empty titles were already handled by the frame renderer
(render_title_left_side returns None for an empty title), including the
sl0p centred/right alignment path.

Baselines held: zellij-server 1436/30, zellij-utils 436/5.
</content>
</entry>
<entry>
<title>sl0p: grayscale the contents of unfocused panes</title>
<updated>2026-08-08T20:36:30Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-08T20:36:30Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppij.git/commit/?id=cfae103a7afae1541a46cbfe21b011ffe4d09f2d'/>
<id>urn:sha1:cfae103a7afae1541a46cbfe21b011ffe4d09f2d</id>
<content type='text'>
`ui { pane_frames { inactive_grayscale 0-100 } }`, default 0 (off).

Real colour transformation, not the SGR 'faint' attribute zellij already uses
for frames: faint is terminal-dependent, several terminals only apply it to
the 16 ANSI colours, and it does not remove colour. Each cell's colour is
resolved to RGB and blended toward its own Rec.709 luminance, so an unfocused
pane keeps its brightness but drains of colour.

Colour resolution covers the cases that actually occur: RgbCode directly,
ColorIndex via eightbit_to_rgb, and NamedColor by mapping to its 0-15 index.
Cells with NO explicit colour are deliberately left alone - they render in the
terminal default, which is already unsaturated, and rewriting them would mean
emitting an explicit colour for nearly every cell for no visible gain.

Two non-obvious pieces were needed to make it actually work:

- The output buffer only emits CHANGED cells, so an idle pane would keep its
  old colours indefinitely after losing focus. Pane::refresh_for_focus_change
  forces a full repaint, called from ActivePanes::insert (the single place
  focus changes). It is a no-op when the feature is off, so it costs nothing
  by default.

- Terminal panes render through the SHARED multi-client path. Focus is per
  client, so once unfocused panes are desaturated two clients may need
  different output for the same pane. Both tiled and floating panes now fall
  back to per-client rendering when grayscale is enabled - the reason this is
  opt-in.

Known limitation: sixel/kitty image chunks are not colour-transformed and stay
in colour.

Hot-reloads like the other sl0p options. Tests cover the transform: full
strength yields a true neutral grey, partial sits between original and grey,
indexed and named colours resolve rather than being skipped, luminance is
perceptual (green brighter than blue) not a flat average, already-grey is
unchanged, and non-colour codes are untouched.

Baselines held: zellij-server 1436/30, zellij-utils 436/5.
</content>
</entry>
</feed>
