<feed xmlns='http://www.w3.org/2005/Atom'>
<title>sl0ppi.git/pi-ext, branch main</title>
<subtitle>a sl0p.foo repository</subtitle>
<id>https://git.sl0p.foo/sl0ppi.git/atom/pi-ext?h=main</id>
<link rel='self' href='https://git.sl0p.foo/sl0ppi.git/atom/pi-ext?h=main'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/'/>
<updated>2026-08-12T17:34:35Z</updated>
<entry>
<title>installers: sync scripts/ CLI helpers into ~/bin</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:28:53Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=2db5d93ce8269a922eba3674597cbcbc1f2a3cb1'/>
<id>urn:sha1:2db5d93ce8269a922eba3674597cbcbc1f2a3cb1</id>
<content type='text'>
The repo already treated extensions as a deliberate snapshot: copy every
*.ts into ~/.pi/agent/extensions, skip files already identical. Standalone
helpers in scripts/ had no such path, so pir had to be copied by hand and
then drifted from the checkout.

CLI_TOOLS is a "&lt;source in scripts/&gt;:&lt;installed name&gt;" list, so the
installed command name can differ from the filename (pi-resume -&gt; pir).
Same copy-not-symlink policy as the extensions, for the same reason: the
global set should not track in-progress edits. Copies are chmod +x, the
destination is PI_BIN_DIR-overridable like PI_EXT_DIR, a missing source is
reported and skipped rather than aborting the whole install, and the entries
participate in the existing --dry-run output and the "nothing changed"
tally.

Also records why answer-picker.ts is absent from the list: it needed a
helper binary in its socket+separate-pane form (c88fe0d), and does not any
more now that zellij draws and hit-tests the buttons itself and reports
clicks on pi's stdin. That implementation is parked in attic/, which this
script does not glob -- top-level *.ts only -- so nothing there installs.
</content>
</entry>
<entry>
<title>add pir: fuzzy-find and resume pi sessions for the current dir</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:28:43Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=402c20c344435ff0dbd736f654601d1ad53de954'/>
<id>urn:sha1:402c20c344435ff0dbd736f654601d1ad53de954</id>
<content type='text'>
`pi -c` blindly grabs the newest session, and the alternative is digging
through ~/.pi/agent/sessions/--home-user-dev-foo--/ for a UUID by hand.
This lists every session for the cwd in an fzf picker with a live transcript
preview and execs `pi --session &lt;file&gt;` on the pick.

  pir              pick a session for this dir
  pir -a           pick across every project dir
  pir &lt;query...&gt;   start fzf with an initial query
  pir -g TEXT      only sessions whose full transcript contains TEXT

The -g pass exists because the fzf query matches the list line -- title and
id -- not deep history, so "the session where I fixed the mouse bug" is
unfindable without it. It shells out to rg when available and falls back to
grep.

In the picker: enter resumes, ctrl-f forks instead (execs --fork, leaving the
original untouched), ctrl-o prints the path and exits without launching,
ctrl-a toggles current-dir vs all-dirs, ctrl-s toggles recent vs longest.
The toggles are fzf --bind=reload of this same script, which is what --list
and --preview are for -- they are internal modes invoked by fzf, not user
entry points.

Session directory resolution mirrors pi's own getDefaultSessionDirPath()
encoding, and honours PI_APP_NAME / &lt;APP&gt;_CODING_AGENT_DIR, so a renamed or
relocated agent dir still resolves. Forked sessions are detected from the
header's parentSession and marked in the list.

Requires fzf; checked up front with a clear error rather than failing inside
a pipeline.
</content>
</entry>
<entry>
<title>add answer-picker: clickable preset answers in the pane frame (OSC 5577)</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:25:46Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=258703d6cb417e170c951d0ac86685633cb8c773'/>
<id>urn:sha1:258703d6cb417e170c951d0ac86685633cb8c773</id>
<content type='text'>
Puts the follow-ups you retype constantly ("continue", "yes", "run the
tests") into the bottom frame row of pi's own pane, using the sl0p zellij
fork's OSC 5577 pane-status-bar protocol. Click one or press alt+N and it is
sent as a real user message.

  ╰ alt+N · ready ──[continue]─[yes]─[no]─[explain]───╯

WHY THE THIRD DESIGN. v1 drew its own button row and enabled terminal mouse
reporting inside pi. It worked and cost too much: mouse reporting is
per-terminal, not per-region, the wheel is just another button in that
protocol (SGR bit 64), and the DECSET modes only select how much MOTION is
reported -- there is no bit that selects which buttons. So pi's pane traded
native wheel scrolling and click-drag selection for clickable buttons, and
because pi renders inline into the main screen it cannot scroll the
emulator's scrollback back to compensate. v2 moved the buttons to a second
pane to confine the tracking; that restored scrolling but cost a pane, a
picker process and a unix socket. OSC 5577 removes the tradeoff instead of
relocating it: zellij draws the buttons in the frame it already owns,
hit-tests them itself, and reports clicks on our stdin. No mouse reporting
anywhere, no extra pane, and the frame row does not shrink the content area
so nothing resizes the PTY. v2 is parked in attic/ (not globbed by
install.sh) as the only non-zellij path, should one be wanted.

ANSWERS come from DEFAULTS until &lt;cwd&gt;/.pi-answers.md exists, after which
the file is authoritative. It is SEEDED with the defaults on first write,
which makes "the file wins" and "you still get the defaults" one rule
instead of a merge order to remember -- and it means deleting a line
genuinely removes a default, which an additive model could not express.
/answers add|rm persists in one step; hand-edit + /answers reload also works.
Labels are backtick-delimited in the markdown so a body may contain the
em-dash separator (as "no -- don't do that" does).

/answers list renders a table via appendEntry + registerEntryRenderer, so it
is scrollable and copyable in the transcript but never enters LLM context.

BUTTON IDS ARE DERIVED FROM THE LABEL (ans-ship), not positional. With a
mutable answer list, positional ids are a live bug: change the answers and
an in-flight click for a vanished button resolves to whatever now occupies
that index and fires the wrong prompt. Label-derived ids fail the allow-list
instead.

BUTTONS EXIST ONLY BETWEEN TURNS. While a run is in flight they are
withdrawn -- so a click cannot queue a second answer behind the one already
working -- and the status text stays up to advertise that alt+N still does.
Withdrawing sends `buttons` with an empty payload, NOT `clear`: the fork's
buttons verb assigns parse_status_bar_buttons(payload), which filter_maps
away every field lacking an id:label, while `clear` is the one verb that
would also wipe the status text. Restoring keys off agent_settled as much as
agent_end, since agent_end never fires on the abort/error paths -- an escape
cancel or a provider blowup would otherwise leave the frame permanently bare.

SECURITY. These are in-band escapes, so anything that writes to the pane (a
cat of a hostile file) can declare buttons and draw a misleading bar. A
click report is a hint that a user pressed something, not proof we put it
there: ids are validated against what we actually declared, and the action
is deliberately non-destructive -- it sends a prompt, it does not deploy.
Click reports arrive interleaved with ordinary stdin and are stripped, or
they would be typed into the editor; pi-tui's StdinBuffer holds an ESC ]
sequence until its ST/BEL terminator, so we always see whole reports.

Caps match the fork exactly (8 buttons, 512-char status, 32-char ids), so we
never rely on zellij silently dropping our excess. Answers past the 8th stay
on alt+N and the status line says so.
</content>
</entry>
<entry>
<title>last-prompt: call it a turn, not a sl0peration</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:25:25Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=4a09a987fd88bbc79ad506d764f33ee0560ed781'/>
<id>urn:sha1:4a09a987fd88bbc79ad506d764f33ee0560ed781</id>
<content type='text'>
The counter increments once per user text block, so what it counts is
exactly a turn in the ordinary prompt -&gt; response sense. It also matches
the vocabulary pi already uses internally (turnBudget, maxTurns), so the
label no longer invents a word for a thing that has one.

"round" was the other candidate and is worse: it implies a symmetric game
with fixed phases, and it collides with the rounded-border language used
throughout these widgets. "prompt #N" is the most literal reading of the
counter, but it undersells the box -- the spinner and timer measure the
whole agent run, not just the prompt.

No layout change was needed. The label is 7 columns narrower, and the top
border derives its dash fill from visibleWidth(leftLabel) rather than any
hardcoded width, so the frame absorbs it.

The cross-reference in slop-tool-boxes.ts ships in 36e422b, whose hunk it
shares.
</content>
</entry>
<entry>
<title>slop-tool-boxes: green heartbeat pulse; unfreeze top border when it fits</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-12T14:25:18Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=521212c3afc3db4b7fbce736b8146608f07f5cba'/>
<id>urn:sha1:521212c3afc3db4b7fbce736b8146608f07f5cba</id>
<content type='text'>
Two changes that both come back to "what should an in-flight box look like",
plus the icon that was left out of it.

PULSE: single hue, brightness only. The old pulse interpolated a 4-color
synthwave ring (amber -&gt; pink -&gt; violet -&gt; cyan) over 4s, which at that
period reads as a slow hue *drift*, not a pulse -- you notice the color has
changed, never that it is beating. Now it is one hue (green, i.e. "in
progress") modulated dim &lt;-&gt; bright: #0d4023 to #3bff7a, via a raised
cosine so there is no direction-reversal snap at the turn, then gamma ^1.7
to bias it toward the dim end. The border rests dark and spikes bright,
which is what makes it read as a heartbeat.

CYCLE_MS 4000 -&gt; 800 (1.25Hz), and 800 is now the only pulse-speed knob.
There is a hard floor of ~400ms documented at the constant: a brightness
pulse IS a flicker, and 3-30Hz is the photosensitive-seizure band -- this
repo has already shipped one seizure-grade flicker bug (NOTES.md), so the
constant carries the warning rather than relying on memory. The upper bound
is render cadence, not taste: the pulse never forces a repaint, it samples
wall-clock time on renders that were happening anyway (~10fps while the
spinner runs), so 800ms is ~8 samples per beat. Much faster and consecutive
renders land on distant brightnesses -- the beat degrades into jitter.

TOP BORDER: freeze only when we actually must. 0d671ce stopped the
full-redraw flicker by freezing the call slot's top border for the whole
time the result streams. That was broader than the bug requires. The bug is
specifically "recolor a line that sits ABOVE the viewport": tui.js does
`if (firstChanged &lt; prevViewportTop) fullRender(true)`, so an off-screen
recolor repaints everything, every tick. While the box still fits on
screen, firstChanged lands inside the viewport and tui diffs from there
down -- cheap. So canPulseTopBorder() compares callH + resultH against
rows - RESERVED_ROWS and only freezes past that, which gives short tool
calls their top and bottom borders beating in unison again.

resultH is one frame stale (the call slot renders before the result slot).
Harmless, because a box only grows: worst case is one extra full redraw on
the frame it crosses the threshold, and then it is frozen. RESERVED_ROWS
(14, for input box + footer + topbar) is deliberately generous in the same
direction -- over-estimating freezes a box that could have animated, which
nobody can see; under-estimating buys back the flicker.

A frozen border now holds HOLD_PAINT, a fixed mid-beat green (pulseRGB(0.5)),
instead of whatever phase the pulse happened to be at when content last
changed. Same number of writes, but it looks deliberately held rather than
randomly stuck. Resuming the pulse invalidates callActiveW so the frozen
lines cannot be served stale.

ICON: the pending ◉ takes the border's paint, so it beats with the frame
instead of sitting there as a static amber dot. This is why the label is
built inside build() now -- it has to be rebuilt per paint. It costs
nothing extra: the icon shares the top border's line, which is already
being rewritten.

Also picks up the sl0peration -&gt; turn doc-comment rename in the header,
which is unified into the same hunk as the pulse comment above it.
</content>
</entry>
<entry>
<title>chrome: indent the input behind a ❯ prompt glyph</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-11T20:32:25Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=28bf22e07222d908fe9760a84578631141c2ad19'/>
<id>urn:sha1:28bf22e07222d908fe9760a84578631141c2ad19</id>
<content type='text'>
Rhymes with the ❯ chip user-prompt-frame stamps on the message once it is
submitted, so what you type and what it becomes read as the same object:

  ❯ your input text
    that wraps here
  ──────────────────────────────────────────────

The file's own comment warned that content lines must pass through
verbatim or the cursor desyncs. That is true but fixable: the TUI derives
the cursor column as visibleWidth(line.slice(0, markerIndex)) from the
FINAL rendered line, so a prefix inserted BEFORE the marker shifts the
reported column by exactly its own width -- which is where the text moved
to. Verified against native: the same text reports col 11 unpatched and
col 13 patched, and empty input parks the cursor at col 2, against the
glyph. Nothing may ever be inserted after the marker.

Layout now runs at (width - INDENT_W) instead of the full width, otherwise
the editor wraps two columns too late and the indent pushes every line past
the terminal edge. The rules it draws at that width are still discarded and
rebuilt at full width.

The glyph is suppressed when the input is scrolled: the first VISIBLE line
is then not the first line of the prompt, so the glyph would lie about
where the input begins. A scroll indicator in the top rule is exactly that
condition.

Glyph uses the instance's own borderColor, so it keeps reflecting bash-mode
and thinking-level like the rule does. The slash/autocomplete menu is left
at column 0 (INDENT_MENU): it draws full-width selection bars that would
otherwise sit offset against the rule above them.
</content>
</entry>
<entry>
<title>chrome: reframe user prompts as a labelled chip + rail</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-11T20:32:15Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=0c9022158ee3d311c819230bc3ef0a425f6d97a5'/>
<id>urn:sha1:0c9022158ee3d311c819230bc3ef0a425f6d97a5</id>
<content type='text'>
The header was lost in 77f1d64: dropping the side rails also dropped
frameTop() from the return, leaving a bare closing rule under the message.
A rule *underneath* reads as an opener for the reply as much as a
terminator for the prompt, and it shared its glyph vocabulary with every
other box, so prompts were distinguished only by hue.

Replace it with a reverse-video chip and a left gutter:

  █ ❯ PROMPT ▌──────────────────────────────────
  ▌ the prompt text
  ▌ wrapped continuation

The chip is the only filled element in the chrome and the rail the only
vertical (rails were removed everywhere else), so prompts are now
identifiable at a glance without adding glyphs to the rest of the UI.
The left cap is a FULL block, not ▐: the rail fills the left half of
column 0, so a right-half cap would sit in the opposite half of the same
cell and read as detached.

No closing rule; the reply terminates the block.

The native component wraps text in Box(pad, 1, bg), i.e. a blank padded
line above and below with OSC 133 markers on the first and last line.
paddingY is hardcoded and not configurable, so strip both pad lines and
re-home the markers: ZONE_START onto the header, ZONE_END+ZONE_FINAL
APPENDED to the last text line (prefixing would put the final line of the
prompt outside the semantic zone). Terminal prompt-jumping keeps working.

Note the OSC payload is not numeric-only -- pi emits 133;A / 133;B / 133;C
-- so the matcher runs to the terminator rather than a [0-9;] class. An
earlier numeric-only class silently matched nothing and left the pad lines
in place.

No timestamp: UserMessageComponent receives only `text`, and stamping at
first render would relabel every restored message with the restore time
under `pi -c`.
</content>
</entry>
<entry>
<title>input-frame: remove header rule and rails, plain separator</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-10T22:09:05Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=456bc7e80ccee97e0435e380b7127972db65577a'/>
<id>urn:sha1:456bc7e80ccee97e0435e380b7127972db65577a</id>
<content type='text'>
The input box now renders as bare text: no top header (the "╭─ ❯ ───╮"
rule is dropped), no side rails, and the bottom is a plain edge-to-edge
rule rather than a ├──┤ divider, so nothing around the input reads as a
box. Content is laid out at the real full width and passed through
verbatim, which keeps the cursor/IME marker exactly where the editor put
it.

Scroll indicators ("↑ N more") still render, since they carry
information rather than chrome; set KEEP_SCROLL_INDICATOR to false to
drop those too. Removed the now-dead corner constants and railWrap, and
renamed buildBorder to buildRule to match what it emits.
</content>
</entry>
<entry>
<title>chrome: drop side rails across boxes (zellij draws the pane border)</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-10T22:08:58Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=129749f0e62526831797ab2ea318a75dd0ce12b0'/>
<id>urn:sha1:129749f0e62526831797ab2ea318a75dd0ce12b0</id>
<content type='text'>
We run the agent inside a zellij fork that already draws a border around
the pane, so per-box verticals were redundant chrome. Body lines are now
indented into the box interior instead of railed, and the top/bottom
rules use straight end caps (a corner would curve toward a vertical that
is no longer there). Each file keeps the original glyphs documented so
the rounded box can be restored by flipping the cap constants back.

Backported from the installed set in ~/.pi/agent/extensions, which had
drifted ahead of this repo.
</content>
</entry>
<entry>
<title>slop-tool-boxes: stop full-redraw flicker on tall, top-clipped active boxes</title>
<updated>2026-08-12T17:34:35Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-07-26T07:32:49Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/sl0ppi.git/commit/?id=84483b6d6020eee944ada9822c5d6229dc567e04'/>
<id>urn:sha1:84483b6d6020eee944ada9822c5d6229dc567e04</id>
<content type='text'>
An in-flight tool box taller than the viewport (top scrolled off-screen)
flickered violently the whole time it was active. Cause: the working spinner
(and pi's default spinner) drive ui.requestRender() at ~10-16fps, and the
active call slot recomputed a wall-clock pulse color every render, recoloring
the TOP border. tui.js falls back to a full-screen redraw when the first
changed line is above the viewport (firstChanged &lt; prevViewportTop), so every
spinner tick = one full clear+repaint.

Fix: once the result slot is streaming (box can be tall), freeze the call
slot pulse color, caching built lines per (width, inner). The header/command
is static, so the top border is byte-identical between render ticks and never
triggers an above-viewport change. The pre-output phase (box still fully
visible) keeps the live pulse + ticking runtime; the result slot still pulses
its in-viewport bottom border. NOTES.md documents this as flicker #4.

Confirmed by repro: massive flicker with the fix reverted, calm with it in.
</content>
</entry>
</feed>
