1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
|
# Handover
Written at the end of a long session. Start here.
## Done: the `+` split button renders (and clicks where it is drawn)
**Feature:** a `+` at the right end of every pane's title line; clicking it
splits that pane. Shipped invisible in 0.1.7 and 0.1.8; fixed after both.
It was never one bug. The title line has three builders, and the button was
falling out of two of them, while the click was hit-testing a rect the frame is
not drawn in:
1. **No title, no right side.** `title_line_without_middle` only computed the
right side *inside* the left side's `and_then`, so a pane with an empty title
got none. Live sets `hide_default_pane_names true`, so unnamed panes have
exactly that.
2. **A "MY FOCUS" middle dropped the right side entirely.**
`title_line_with_middle` fell through to `middle_only_title_line` whenever
the left side was absent. Any second client in the session puts a middle
there — including the phantom client of open item 1 below, which is why the
live box *always* took this path (`MY FOCUS AND:` on every frame was the
clue, and it was in the bug report all along).
3. **A long title ate the button.** The title was budgeted first and the right
side got the remainder; shell titles are long paths. The anchored button is
now reserved before the title, since its hit-test is pure geometry and cannot
ask the renderer what fitted.
4. **The click hit-tested `position_and_size()`**, the pane's allocation, while
the frame is drawn inset by the pane gap (`gap 1` → 2 columns, 1 row). The
clickable columns sat out in the blank gap, two right of the drawn `+`. This
is why "clicking works" and "nothing is drawn" were both true, and why that
click test proved nothing. There is now `Pane::frame_rect()` — the rect the
frame is actually drawn in — and both the button and (already) the status bar
buttons derive from it.
5. **Stacks put the frame somewhere else again**, found only after the above
shipped and the button was still dead on a live session:
- a stack list is drawn over the top rows of its visible member, pushing that
pane's frame down (`reserved_top_rows`, applied by the renderer as a frame
geom override). `frame_rect_with_top_reserved()` applies the same shift, or
every pane in a stack is clickable one row above its own title;
- a stack's **collapsed** members drew a button on their one-row title, which
nothing could honour — that row is the stack's picker. They no longer draw
one (120 snapshot lines, all of them exactly that), and the click side
mirrors the condition.
The centre-aligned path, the strong lead in the previous handover, was fine.
**What was missing was the reproduction, not the analysis.** Two things now
exist so this cannot come back:
- `split_button_is_drawn_where_it_is_hit_tested` — asserts the button occupies
exactly `split_button_columns()` across alignments × rounded × titled/untitled
× one/two clients × 4 widths. Positional, not `contains("+")`: the short
multi-client indication is itself a `+`.
- `packaging/checks/split-button-live.py` (`make check-live`) — spawns a real
session at a pty, reads the glyph off the screen, attaches a second client to
force the middle builder, splits and stacks, and then clicks **every** button
it can see, expecting one new pane each time. Run twice: with a minimal config
and with `config/config.kdl`, because the minimal one has no stacks and that
is where the second round of bugs lived. Each fix was confirmed by putting it
back and watching the check fail.
Two things about that check are load-bearing, both learned the hard way when it
reported failures that were its own fault:
- **read until the pty goes quiet**, not for a fixed time. A repaint arrives in
many chunks and half of one looks exactly like a button drawn where it is not.
- **force a full repaint (resize) and parse only that**, because the screen
model here is 80 lines of regex, not a terminal, and stale cells from a
previous layout are indistinguishable from real ones.
When the check and the code disagree, `log::info!` in
`intercept_split_button_click` and read `/tmp/sl0ppij-$UID/sl0ppij-log/` — the
server's own numbers settled it in one run, after an hour of screen-scraping
theories.
One more bug fell out of the width assertion: `render_my_focus` built its full
and short forms from the same separator vectors, and `Vec::append` drains — so
the short "ME" form rendered 4 cells wide while reporting 6, two cells short of
the pane. Fixed.
Code involved:
| what | where |
|---|---|
| render + geometry | `ui/pane_boundaries_frame.rs`: `render_split_button`, `split_button_columns`, `anchored_right_side_len`, `SPLIT_BUTTON_*` |
| click | `tab/mouse_handler.rs`: `intercept_split_button_click` |
| where the frame really is | `tab/mod.rs`: `Pane::frame_rect` (+ the two impls) |
Not drawn on floating panes on purpose: the PIN control owns that corner and is
hit-tested by its drawn position, so anything to its right moves it out from
under the pointer (`test_ctrl_click_on_floating_pin_button_toggles_pin`).
## Also done: shipped plugins no longer ask for permission
pane-finder opened a three-item consent screen the first time you pressed its
keybinding. Plugins we ship are now granted implicitly --
`plugin_is_shipped_with_sl0ppi` in `zellij-utils/src/input/permission.rs`,
checked by `packaging/checks/plugin-permissions-live.py`.
The part to keep in mind: **`<config dir>/plugins/` is now a trusted
directory** (documented in `config/README.md`). The same wasm from anywhere else
still prompts, and remote plugins are never trusted. If you ever need the
opposite for a specific plugin, the honest fix is to load it from outside that
directory rather than to widen the rule.
## Where the project is
Everything is committed and pushed to `ssh://git@git.sl0p.foo:39327/srv/git/sl0ppi.git`.
Released artifacts are at <https://sl0ppi.sl0p.foo> (currently 0.1.9).
| phase | state |
|---|---|
| monorepo, 3 vendored forks with history | done |
| rename (sl0ppij coexists with zellij) | done |
| portable config | done |
| tarball + installer, verified in a clean room | done |
| `sl0ppi` CLI MVP (`up`/`ls`/`open`/`close`/`init`/`print`/`doctor`) | done |
| site + release scripts (no CI, by choice) | done |
`make` for targets, `make test` for both suites, `make release && make publish`
to cut and upload a version, `packaging/publish.sh` verifies against the live
URL afterwards.
## Open, in the order I would do them
1. **Phantom client on headless `sl0ppi up`.** `attach --create-background`
leaves one client in the session forever, so every pane frame shows
`MY FOCUS AND:`. Worked around by creating the session *by attaching* when
there is a tty; the headless path still leaks. Ruled out: the CLI-action
clients, and the creating client exiting too early (making it wait for a
server ack changed nothing). `screen.remove_client()` does clear
`connected_clients` with no early return, so instrument
`ScreenInstruction::RemoveClient` on a debug build to find where it is lost.
Cheap alternative: give the headless path its own pty (`script -qec`) and
kill the client afterwards — killed clients demonstrably do not leak.
2. **`pir` needs python3 + fzf**, neither shipped. The fix is folding it into
`sl0ppi agent --resume` (already designed in `docs/cli-design.md`).
3. **Tab overflow.** `sl0ppi open <name>` wants to be a fuzzy picker once a root
has a dozen projects.
4. Button polish: shift/right-click for the other split direction; reuse
`status_bar_button_style` so it looks pressable. Extend `make check-live`
rather than trusting a snapshot.
Fixed this session, for context: client ids were recycled (silently ate ~25% of
`zellij action` output), suspended panes spammed resize errors, responsive
layouts resolved against `visible_panes_count()` and never really expanded,
runtime `new-tab` dropped pane/tab purposes, and generated sessions had no tab
bar. See `docs/roadmap.md` for each with its measurements.
## Traps worth knowing (all cost me time here)
- **`pkill -f` / `pgrep -f` match your own shell's command line.** I killed my
own script three times, and twice killed the user's live clients. Match on
`comm` instead: `ps -eo pid,comm,args | awk '$2=="sl0ppij"'`.
- **Test sessions live in the runtime dir**, not `TMPDIR`. Only
`SL0PPIJ_SOCKET_DIR` isolates them. A stale session called `dev` was silently
reused across tests.
- **`INSTA_FORCE_UPDATE=1` churns hundreds of snapshots** (strips
`assertion_line`, trailing newlines). Update per-test with
`INSTA_UPDATE=always cargo test ... <test_name>` and verify the diff contains
only what you expect.
- **git paths in loops**: `git diff --name-only` prints repo-root-relative
paths; running `git checkout --` on them from inside `sl0ppij/` silently
fails.
- **Single-line KDL needs `;` between nodes.** `tab x { pane }` will not parse;
emit multi-line.
- **A tab template used to destroy tab purposes** — `parse_tab_node_with_template`
never read `purpose` off the tab node, so the template's (none) won. Fixed;
`sl0ppi` emits `default_tab_template` again, which is also what gives a tab
created at runtime a status bar. If you ever revert that line, the CLI has to
go back to inlining, and runtime tabs lose their bar.
- **The tab test harness does no rendering**, so `panes_to_hide` is always empty
and stack-related bugs cannot be reproduced in it. Use a scripted pty.
- **`action list-panes` hides borderless plugin panes once a client attaches.**
The status bar vanishes from its output while being drawn on screen the whole
time. `dump-layout` and the screen are the truth; an hour went into a bug that
only existed in that JSON.
- **The generated layout is a file of its own.** Anything `layouts/default.kdl`
refers to -- pane templates especially -- has to be copied into it, and its
swap layouts must NOT be (see roadmap: the bar disappears when auto_layout
swaps a tab that has no `default_tab_template`).
- **A hit-test that never consults the renderer is not evidence of anything.**
The split button "worked" for two releases while being invisible. If an
affordance is drawn, the check has to *find the glyph* and click where it
found it — `packaging/checks/split-button-live.py` is the pattern.
- **`os.read` on a settled pty blocks forever.** `os.set_blocking(fd, False)`
and catch `BlockingIOError`, or a live check hangs instead of failing.
- **The pane's geometry is not where its frame is drawn.** With a gap, the frame
is inset from the allocation: use `Pane::frame_rect()` for anything the user
can see, `position_and_size()` only for the layout solver.
- **Never kill a `sl0ppij` you cannot PROVE is yours.** I killed the user's live
client on the reasoning "there are no user sessions on this box" -- a guess,
not evidence, and wrong. The evidence was right there and said the opposite: a
check's processes always carry that run's own socket dir
(`--server /tmp/sl0p-<run>/...`) or `attach sl0ptest-...`, while a bare
`sl0ppij` with a cwd in the repo is a human's. If the args do not contain your
own temp dir, it is not yours.
- **Clean up test sessions, and DELETE them.** A background-created session I
left behind is what produced a "MY FOCUS" bug report from the user, and
`kill-session` alone leaves them listed as "EXITED - attach to resurrect":
35 of mine had silently filled the session picker before I noticed. The live
checks now `delete-session` their own session by name; anything you spawn by
hand, clean up the same way.
- **`send_cli_scroll_up_action` flakes** (scrolled 1 line where the snapshot has
2), seen once in ~10 full runs. It is the CLI-action raciness already tracked
above, not a rendering bug -- re-run before chasing it.
## Access
- test box: `ssh root@10.10.10.130` (Debian, 2 vCPU; where every live check ran)
- prod: `ssh root@sl0p.foo`; nginx config lives in `~/dev/sl0pday/nginx/sl0p.conf`,
deployed with `./deploy.sh nginx/sl0p.conf` (that repo has **no** git remote)
- `sl0ppi.sl0p.foo` currently answers **401 to everyone except 87.209.3.148**
(our egress). Lift by deleting the `geo` block and the `if` in the vhost.
- new git repos: `ssh root@sl0p.foo git-new-repo <name>`
- releases need `cargo-zigbuild` + the `ziglang` python module (both installed);
`protoc` 25.1 is in `~/.local/bin` for `cargo xtask proto`.
## How to verify things here
Unit tests are necessary and have repeatedly been insufficient — three of this
session's bugs passed them. For anything touching panes, layouts or the mouse,
drive a real session:
```python
# pty at a chosen size; resize or inject SGR mouse events mid-session
pid, fd = pty.fork()
if pid == 0: os.execvp("sl0ppi", ["sl0ppi"])
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
os.write(fd, b"\x1b[<0;COL;ROWM") # left press, 1-based coords
```
then read the truth back out of `sl0ppij -s <session> action list-panes --json`
(x, y, cols, rows per pane — two panes sharing a rect means a stack) and strip
ANSI from the pty output before grepping for glyphs.
|