<feed xmlns='http://www.w3.org/2005/Atom'>
<title>sl0ppi.git/sl0ppij/zellij-utils/src/plugin_api, branch main</title>
<subtitle>a sl0p.foo repository</subtitle>
<id>https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij/zellij-utils/src/plugin_api?h=main</id>
<link rel='self' href='https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij/zellij-utils/src/plugin_api?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/'/>
<updated>2026-08-12T20:05:44Z</updated>
<entry>
<title>sl0p: carry pane and tab purposes over the wire</title>
<updated>2026-08-12T20:05:44Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T20:05:44Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=5873374a1d8cc75b9abccdb8617f0f964af50d4d'/>
<id>urn:sha1:5873374a1d8cc75b9abccdb8617f0f964af50d4d</id>
<content type='text'>
`new-tab --layout` / `--layout-string` created the tab and silently dropped
every purpose the layout declared -- the tab's own (which rides the tab's root
layout) and each pane's. Layouts applied at session creation kept theirs, so
this only broke tabs added at runtime, which is exactly what an orchestrator
does. Found by integration-testing the sl0ppi CLI against a real session rather
than trusting that the documented feature worked on this path.

Cause: TiledPaneLayout and FloatingPaneLayout had no protobuf `purpose` field,
so anything travelling as an Action lost it. The original workspace-hints commit
says as much in a comment -- "adding a proto field would force regenerating the
committed assets" -- because generated prost output is checked in and protoc was
not available. It is now (a user-local protoc 25.1, no sudo, no system package),
so the deferral can be paid off properly instead of worked around.

There are two protobuf schemas and the distinction matters: plugin_api/ is what
plugins speak, client_server_contract/ is what the CLI speaks. The first attempt
fixed only plugin_api and changed nothing observable; both now carry the field.

  plugin_api/action.proto             TiledPaneLayout.purpose = 14
                                      FloatingPaneLayout.purpose = 13
  client_server_contract/common_types TiledPaneLayout.purpose = 16
                                      FloatingPaneLayout.purpose = 15

Purposes are sanitised on ingest, so a value arriving over IPC obeys the same
invariant as one parsed from a layout file (bounded token, &lt;=64 chars) whoever
constructed the message. Verified live: a tab declaring `rm -rf /; echo "x"` is
stored as `rm-rf/echox`, and a 200-char pane purpose arrives capped at 64.

Tests: two new wire round-trips (a NewTab layout carrying tab, pane and floating
purposes; and the sanitiser). zellij-utils 473 passed, zellij-server 1499
passed. Live: `new-tab --layout-string` now reports
purpose='project:added/nested' with panes 'agent:main' and 'logs:app'.
</content>
</entry>
<entry>
<title>sl0p: --purpose flag and tab-level purpose</title>
<updated>2026-08-12T17:34:20Z</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/sl0ppi.git/commit/?id=21962a35caf351db30150a2b4cdcc30d76ff5fd7'/>
<id>urn:sha1:21962a35caf351db30150a2b4cdcc30d76ff5fd7</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: per-pane workspace hints (purpose=)</title>
<updated>2026-08-12T17:34:19Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T15:05:34Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=21fbcf3c68d38423d994f529a4653b9cd77fc4c0'/>
<id>urn:sha1:21fbcf3c68d38423d994f529a4653b9cd77fc4c0</id>
<content type='text'>
Implements the zellij half of sl0p/workspace-hints-proposal.md: a pane can carry
an opaque agent-facing hint for what it is FOR, so a project can ship a layout
that an agent is able to navigate instead of guessing.

zellij never interprets a purpose. The consuming agent owns the entire vocabulary,
which is what keeps this to one string threaded along the existing status_bar path
rather than a policy engine, and lets the vocabulary change without a rebuild.

Two ways in, because declared panes and agent-created panes are different:
  layout:  pane name="server" purpose="dev-server"
  in-band: printf '\033]5577;1;purpose;agent:build\033\\'
The second exists because tagging at spawn time would mean threading a field
through 28 SpawnTerminal call sites, and a new action variant is impossible while
protobuf enums cannot be regenerated. Wrapping the command costs one printf.

A declared purpose outranks an in-band one and cannot be overridden or cleared by
it. OSC arrives from whatever the pane happens to be showing, so otherwise a
`cat hostile.txt` in the human's editor pane could relabel it as the agent's
scratch pane and invite an agent to run commands where the human is working. A
layout is written by a human in a file they can read; in-band data only fills a
blank.

Exposed on PaneListEntry (`list-panes --json`) rather than PaneInfo, which crosses
the plugin protobuf API we cannot regenerate; resolved in the screen thread where
panes live rather than via route.rs's per-pane PTY round-trips. Values are
sanitised to a bounded [A-Za-z0-9_.:/-] token at both entry points, because a
purpose is never rendered -- it is matched against and pasted into consumers'
shell and JSON pipelines.

Verified end to end rather than by inspection: purposes from a layout appear in
list-panes --json against the right pane ids, an agent-spawned pane tags itself,
a declared purpose survives an in-band override attempt, all of it comes back
after kill + resurrect, an unknown pane property is still a parse error, and a
layout with no purpose leaves dump-layout unchanged.
</content>
</entry>
<entry>
<title>sl0p: add an indexed style for status bar buttons</title>
<updated>2026-08-12T17:34: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/sl0ppi.git/commit/?id=bc315b0c7bf1b0201a5164c8c52410a47871fc79'/>
<id>urn:sha1:bc315b0c7bf1b0201a5164c8c52410a47871fc79</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: clickable action buttons in the pane status bar</title>
<updated>2026-08-12T17:34:19Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T13:18:33Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=639ec1a64e7e111f3f7273cdbe75b151a7982a58'/>
<id>urn:sha1:639ec1a64e7e111f3f7273cdbe75b151a7982a58</id>
<content type='text'>
Second slice of the status/action frame. A program declares buttons, and a click
is reported back to it:

    printf '\033]5577;1;buttons;approve:Approve;reject:Reject\033\\'
    -&gt; click -&gt; program's stdin receives  OSC 5577 ; 1 ; click ; approve  ST

The report is a FIXED format carrying only the id, never bytes the payload chose,
and ids are restricted to [A-Za-z0-9_-]. The sequence that creates a button is
in-band and untrusted -- `cat` of a hostile file emits it as readily as a
cooperating program -- so a click that could send arbitrary bytes would let
viewing a file inject into the stdin of whatever is running, a shell included.
Labels are '%'-escaped since the wire format is ';'-separated, and both the
button count and id length are capped because the list lives as long as the pane.

Positions come from one layout_status_bar function that the renderer draws from
and the hit-test resolves against. Computing that geometry twice is how the
stacked-pane picker once selected the wrong pane; a test asserts every drawn
column resolves back to the button drawn there, across 100 widths.

Two things the drive caught that unit tests could not:

- The first delivery path pushed onto grid.pending_messages_to_pty, which is
  only drained when the PANE produces output -- so a click on an idle program sat
  in the queue forever. The write now happens at the tab level, which owns the
  senders.
- The hit-test derived the frame row from the pane's raw geometry, but this fork
  draws the frame inset by gap_offset, so with `gap 1` every click missed by one
  row. It now derives from the CONTENT rect, which is by definition the inside of
  the drawn frame and therefore the same columns the renderer lays out.

Buttons serialise with the session like the status text, so a resurrected pane
comes back with its buttons intact.
</content>
</entry>
<entry>
<title>sl0p: serialise the pane status bar for resurrection</title>
<updated>2026-08-12T17:34:19Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T12:52:13Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=e349b110a83bf0f69771bf2a4d6dae18c194cf14'/>
<id>urn:sha1:e349b110a83bf0f69771bf2a4d6dae18c194cf14</id>
<content type='text'>
The status bar survived detach/reattach (it lives on Grid) but not a
serialise/restore cycle, so a resurrected pane came back blank.

It now travels the whole per-pane serialisation pipeline, mirroring the fork's
existing default_fg/default_bg: pane -&gt; PaneLayoutMetadata -&gt;
PaneLayoutManifest -&gt; TiledPaneLayout -&gt; a status_bar property in the serialised
KDL, then back through the normal layout parser and applied by LayoutApplier
when the pane is recreated.

The sanitiser moved from grid.rs into zellij-utils so both entry points share
one definition. That matters: a layout file on disk is no more trustworthy than
the escape sequence that wrote it -- it may have been hand-edited, or copied from
somewhere else -- so restored text is stripped and length-capped on the way in
just like the OSC payload was.

Verified end to end rather than by unit test alone: set the bar, waited for a
serialisation pass, confirmed status_bar="..." in session-layout.kdl, killed the
session until list-sessions reported EXITED, then attached and watched it come
back from disk. The earlier attempt only proved detach/reattach because Quit had
left the session alive.
</content>
</entry>
<entry>
<title>sl0p: add close_if_focused to LaunchOrFocusPlugin</title>
<updated>2026-08-12T17:34:19Z</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/sl0ppi.git/commit/?id=128d4dfba1c482d304c5176c10b172bebd7c0f0e'/>
<id>urn:sha1:128d4dfba1c482d304c5176c10b172bebd7c0f0e</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 `arrangement` to build a tree for N panes</title>
<updated>2026-08-12T17:34:19Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-10T22:50:30Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=df5841989106401db48d8bac6b2bb85da4501fde'/>
<id>urn:sha1:df5841989106401db48d8bac6b2bb85da4501fde</id>
<content type='text'>
A `children` placeholder inserts every pane it receives as a flat sibling.
That single hardcoded policy is why a layout can only express "N equal
columns", "N equal rows" or "one stack" without hand-writing a variant per
pane count -- the max_panes=5 / max_panes=8 / max_panes=12 ladders in a swap
layout file exist purely to work around it.

`pane arrangement="grid" { children; }` builds a tree for N panes instead.
Two to start: grid (ceil(sqrt(n)) columns, rows filled left to right) and
master-stack (one 60%-wide master, the rest beside it).

Generates a TiledPaneLayout rather than geometry, so everything downstream
works unchanged: split_space, the minimum-size feasibility check,
LayoutApplier, swap-layout damage tracking, and the responsive collapse. It
composes with that last one for free -- a grid is simply infeasible on a
narrow terminal, so it is skipped and the collapse takes over.

The arrangement builds ONE self-contained subtree rather than splicing a
variable number of nodes in among the placeholder's siblings, so it stays well
defined next to the pre-existing "children node with sibling nodes" TODO
rather than leaning on it.

Like default_fg/default_bg, the field has no protobuf representation, so it is
defaulted at both plugin-API boundaries and does not survive a round trip.
This is why it is a field rather than a LayoutConstraint variant: a new enum
variant would have no representation when converting TO protobuf, and the
committed prost assets cannot be regenerated here (no protoc).

The 48 snapshot updates are the new field appearing in TiledPaneLayout debug
dumps (324 lines, all `children_arrangement: None`). No snapshot content was
removed. The `expression:` metadata churn in those files is pre-existing drift
that regeneration swept up: the stored snapshots recorded the expression
before normalize_layout_debug was introduced.
</content>
</entry>
<entry>
<title>sl0p: optionally hide the 'Pane #N' placeholder title</title>
<updated>2026-08-12T17:34:19Z</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/sl0ppi.git/commit/?id=425cf121f9920e6f4a06aa4a185e87be004246b3'/>
<id>urn:sha1:425cf121f9920e6f4a06aa4a185e87be004246b3</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-12T17:34:19Z</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/sl0ppi.git/commit/?id=dead52c7dcc00b1e9013df14dc3b40dad7871022'/>
<id>urn:sha1:dead52c7dcc00b1e9013df14dc3b40dad7871022</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>
