<feed xmlns='http://www.w3.org/2005/Atom'>
<title>sl0ppi.git/sl0ppij/zellij-utils/src/input, branch main</title>
<subtitle>a sl0p.foo repository</subtitle>
<id>https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij/zellij-utils/src/input?h=main</id>
<link rel='self' href='https://git.sl0p.foo/sl0ppi.git/atom/sl0ppij/zellij-utils/src/input?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/'/>
<updated>2026-08-13T20:07:45Z</updated>
<entry>
<title>sl0p: keep a templated tab's purpose, so runtime tabs get a status bar</title>
<updated>2026-08-13T20:07:45Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T20:07:45Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=a8ea4e888938e562885b1a0f009f5743939a879b'/>
<id>urn:sha1:a8ea4e888938e562885b1a0f009f5743939a879b</id>
<content type='text'>
Clicking the bar's `+` made a tab with no status bar. The button only exposed
it: pressing `n` in TAB mode did the same, in any `sl0ppi up` session.

The chain: a generated layout had no default_tab_template, so a tab created at
runtime had nothing to take a bar from. It had none because
parse_tab_node_with_template read a tab node's name, cwd and focus but never
its `purpose` -- so templating erased the purpose that IS identity here
(`sl0ppi ls` finds project tabs by it), and the CLI worked around that by
inlining the template into every tab instead of emitting it.

Fixed at the root: a templated tab keeps its own purpose, and the CLI emits
`default_tab_template` again. The generated layout also shrinks -- one copy of
the bar's configuration instead of one per tab.

The two properties trade off against each other, so sl0ppi-up-live.py now
asserts both at once: `sl0ppi ls` still sees a tab per project, and every tab
including one created at runtime has exactly one status bar. Confirmed by
reverting each half separately: without the parser fix the tabs lose their
purpose and `ls` shows "alpha —"; without the CLI change the runtime tab has
zero bars, which is the reported bug.

Two measurement traps cost an hour here and are now written down: counting bars
by grepping dump-layout also counts the template and every swap layout ("4 bars
for 3 tabs"), and list-panes stops reporting borderless plugin panes for a tab
once a client has rendered it.

Also in here, from the same session:

- the split button check found buttons by searching for " + " on any row with a
  frame glyph, which matches the stack list's "+N more" once two panes sit side
  by side, and then reports the product broken. It now requires the frame
  corner the button is drawn against.
- the checks killed their sessions but did not delete them, so each run left an
  "EXITED - attach to resurrect" entry: 35 had quietly filled the session
  picker. They now delete-session by name.
- and the trap that cost the user their running client: I killed a bare
  `sl0ppij` because "there are no user sessions on this box". That was a guess,
  not evidence, and wrong. A check's processes always carry that run's own
  socket dir or `attach sl0ptest-...`; a bare `sl0ppij` with a cwd in the repo
  is a human's. Written into the handover next to the `pkill -f` trap, which is
  the same mistake in a different costume.
</content>
</entry>
<entry>
<title>sl0p: the plugins we ship do not ask for permission</title>
<updated>2026-08-13T18:08:50Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-13T18:08:50Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=fb7076efd6d3ab5c1d36b6e554045d87b43c15b6'/>
<id>urn:sha1:fb7076efd6d3ab5c1d36b6e554045d87b43c15b6</id>
<content type='text'>
pane-finder requests three permissions as it loads, so the first press of a
keybinding we ship opened a consent screen instead of a fuzzy finder. Prompting
is right for a wasm you found on the internet and wrong for the ones that come
in our own tarball.

plugin_is_shipped_with_sl0ppi (zellij-utils/src/input/permission.rs) grants
those implicitly, through the same instruction a human "y" produces, so the
plugin's permissions are set and recorded exactly as before. "Ours" is narrow
on purpose:

- builtins, asked via the existing PluginConfig::is_builtin -- the same
  predicate check_command_permission already uses to exempt them, so the two
  cannot drift;
- files inside a plugin directory we install into: the config dir's plugins/
  and the data dir's plugins/;
- nothing else. Remote plugins are never trusted, paths are normalised
  lexically first so plugins/../../evil.wasm is not "inside" the dir, and the
  directories are derived from the environment and default locations -- so
  getting them wrong costs a prompt and can never grant one elsewhere.

The trade, documented in config/README.md: &lt;config dir&gt;/plugins/ is now a
trusted directory. Anything able to write there can already bind arbitrary
commands to a key in config.kdl, so a consent screen for the wasm beside it was
not buying anything -- but it does mean plugins/ is not a download folder.

packaging/checks/plugin-permissions-live.py drives both halves on a real
session: the shipped plugin must run without the prompt, and the SAME wasm
loaded from outside the plugin dir must still ask. Confirmed by disabling the
rule and watching the first half fail with the exact "asks permission to ...
Allow? (y/n)" screen it was written against.
</content>
</entry>
<entry>
<title>sl0p: rename to sl0ppij behind a branding seam</title>
<updated>2026-08-12T18:02:26Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T18:02:26Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=f0a56905d9d64034797bae1a457ada8f659321b7'/>
<id>urn:sha1:f0a56905d9d64034797bae1a457ada8f659321b7</id>
<content type='text'>
Makes the fork installable and runnable beside a stock zellij: separate binary
name, config dir, cache dir, socket dir, log file and plugin URL scheme.

Everything user-visible is defined once in zellij-utils/src/branding.rs and
referenced from the ~15 places that used to hold a literal, rather than sed-ing
the tree -- upstream has 3300 commits of future rebases, and each touched
upstream line is a conflict forever. A conflict in branding.rs itself always
resolves the same way: keep our constant, take their code.

  binary        zellij            -&gt; sl0ppij
  config dir    ~/.config/zellij  -&gt; ~/.config/sl0ppij
  system cfg    /etc/zellij       -&gt; /etc/sl0ppij
  cache/data    ProjectDirs(Zellij) -&gt; ProjectDirs(foo.sl0p.sl0ppij)
  socket/tmp    /tmp/zellij-$UID  -&gt; /tmp/sl0ppij-$UID
  log           zellij-log/zellij.log -&gt; sl0ppij-log/sl0ppij.log
  plugin urls   zellij:tab-bar    -&gt; sl0ppij:tab-bar (zellij: still parses)

Two things deliberately NOT renamed:

  - The cargo package stays `zellij`; only [[bin]] is renamed. Renaming the
    package would ripple through every intra-workspace dep, the plugin API and
    the 0.45.0+sl0p1 version trick for a name nobody sees.

  - The $ZELLIJ* environment variables stay, because the ecosystem (shell
    prompts, "am I in a multiplexer" checks, our own pi-ext) looks for them. We
    now export $SL0PPIJ* alongside them, and those take precedence on read.
    branding::import_compat_env() folds an inherited ZELLIJ_CONFIG_DIR into its
    SL0PPIJ_ spelling at startup, before clap reads the environment -- clap can
    only be told one variable name per argument.

Snapshots: 14 updated, all of them the plugin URL scheme or a session message.
The insta churn (assertion_line metadata, trailing newlines) that a bulk
--force-update introduces across 340+ other snapshots was reverted, so the diff
here is only what actually changed.

Tests: zellij-utils 471 passed, zellij-server 1499 passed. branding.rs carries
its own tests, including one asserting no shipped default path contains the
string "zellij".

Verified live: sl0ppij creates /tmp/sl0ppij-1000 next to an existing
/tmp/zellij-1000, and `sl0ppij list-sessions` reports none while a stock zellij
reports three running sessions.
</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: separate the hover resize hint from the hover highlight</title>
<updated>2026-08-12T17:34:19Z</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/sl0ppi.git/commit/?id=a6ead47dcfeb8f019c1e98c826c4f4a09e05d174'/>
<id>urn:sha1:a6ead47dcfeb8f019c1e98c826c4f4a09e05d174</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-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>
</feed>
