aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-11 22:32:15 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commit0c9022158ee3d311c819230bc3ef0a425f6d97a5 (patch)
treee2756aee2aea5e8c83703d05816a8c4e51b518c1
parentinput-frame: remove header rule and rails, plain separator (diff)
downloadsl0ppi-0c9022158ee3d311c819230bc3ef0a425f6d97a5.tar.gz
sl0ppi-0c9022158ee3d311c819230bc3ef0a425f6d97a5.tar.xz
sl0ppi-0c9022158ee3d311c819230bc3ef0a425f6d97a5.zip
chrome: reframe user prompts as a labelled chip + rail
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`.
-rw-r--r--pi-ext/user-prompt-frame.ts198
1 files changed, 146 insertions, 52 deletions
diff --git a/pi-ext/user-prompt-frame.ts b/pi-ext/user-prompt-frame.ts
index b1e40f6..089dff3 100644
--- a/pi-ext/user-prompt-frame.ts
+++ b/pi-ext/user-prompt-frame.ts
@@ -1,18 +1,48 @@
/**
* User Prompt Frame (monkeypatch)
*
- * Wraps submitted user prompts in a rounded sl0p-style border to match the tool
- * boxes. pi has no extension hook for rendering built-in `role: "user"` messages
- * (the transcript dispatch hardcodes UserMessageComponent), so this reaches into
+ * Reframes submitted user prompts as a labelled block that stands out from the
+ * rest of the chrome:
+ *
+ * █ ❯ PROMPT ▌─────────────────────────────────────────
+ * ▌ decribe what user-prompt-frame.ts solves
+ * ▌ and how does it currently render?
+ *
+ * The chip's LEFT cap is a full block, not a half block: the rail glyph ▌ fills
+ * the left half of column 0, so a right-half-block cap (▐) would sit in the
+ * opposite half of the same column and read as detached. A full block covers
+ * both halves, so the chip and the rail join into one continuous edge.
+ *
+ * The design is deliberately the odd one out. Every other box in this extension
+ * set (tool boxes, input frame, footer) is a full-width horizontal rule with no
+ * verticals and no fills, because the zellij pane draws its own border. So a
+ * left rail plus a reverse-video chip is instantly identifiable at a glance
+ * without adding a new glyph vocabulary to the rest of the UI.
+ *
+ * There is NO closing rule. A rule underneath a prompt is ambiguous — it reads
+ * as an opener for the assistant's reply as much as a terminator for the
+ * prompt. The block opens with the chip, the rail carries it, and the reply
+ * itself terminates it.
+ *
+ * pi has no extension hook for rendering built-in `role: "user"` messages (the
+ * transcript dispatch hardcodes UserMessageComponent), so this reaches into
* internals and patches `UserMessageComponent.prototype.render` at load time.
+ * It touches NO files on disk: the class we import is the same module instance
+ * pi constructs from, so patching the shared prototype reframes pi's own
+ * instances.
*
- * It touches NO files on disk: the class we import from the package is the same
- * module instance pi constructs from, so patching the shared prototype reframes
- * pi's own instances.
+ * Native output shape we build on (see pi-tui Box):
+ * UserMessageComponent wraps the text in Box(outputPad, 1, bg=userMessageBg),
+ * so orig.render() returns a background-filled slab with ONE BLANK PAD LINE
+ * above and below, and OSC 133 shell-integration markers prefixed onto the
+ * first and last line. We strip both pad lines (paddingY is hardcoded 1 and
+ * is not configurable) and re-home the OSC markers so prompt-zone jumping in
+ * the terminal keeps working. Content lines pass through with their native
+ * left pad and bg fill intact.
*
- * The border uses a fixed truecolor (FRAME_HEX) rather than a theme token, so it
- * is independent of the shared `accent` color and needs no access to pi's
- * internal theme singleton. Change FRAME_HEX / LABEL below to restyle.
+ * Colors are fixed truecolor rather than theme tokens, so this stays
+ * independent of the shared `accent` color and needs no access to pi's internal
+ * theme singleton. Restyle via the knobs below.
*
* Safety:
* - idempotent + reload-friendly (always re-wraps from the captured original)
@@ -22,25 +52,29 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { UserMessageComponent } from "@earendil-works/pi-coding-agent";
-import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
+import { visibleWidth } from "@earendil-works/pi-tui";
// ── Style knobs ─────────────────────────────────────────────────────────────
-const FRAME_HEX = "#d3869b"; // border + label color (mauve). Change me.
-// Corner prompt glyph + cursor. Minimal/glyphic "❯▁" reads like a shell
-// prompt. CURSOR alternatives: "▂", "_", "_", or "" for none. LABEL adds an
-// optional word after the glyph (e.g. "usr" -> "❯▁ usr").
-const PROMPT_GLYPH = "❯";
-const CURSOR = "▁";
-const LABEL = "";
+const FRAME_HEX = "#d3869b"; // rail + chip background + header rule (mauve)
+const CHIP_FG_HEX = "#1d2021"; // text color INSIDE the chip (dark, on mauve)
+const CHIP_TEXT = "❯ PROMPT"; // set to "" for a bare cap-to-cap chip
+// End caps. Disable if your font renders U+2588/U+258C at odd widths.
+const CHIP_CAPS = true;
+// Full block, NOT ▐: must occupy the same half-cell as RAIL below it so the
+// chip reads as attached to the gutter rather than floating beside it.
+const CAP_L = "█";
+const CAP_R = "▌"; // left half block -> right cap, tapers into the rule
+const RAIL = "▌"; // per-line gutter next to the prompt body
+// One plain blank line after the block. Cheap breathing room now that the
+// native bottom pad line is stripped.
+const TRAILING_BLANK = true;
+// NOTE: no timestamp knob. UserMessageComponent receives only `text` — no time
+// — and stamping at first render would label every restored message with the
+// restore time on `pi -c`. Same class of bug as the pulsing border in
+// slop-tool-boxes.ts. Not worth the lie.
// ────────────────────────────────────────────────────────────────────────────
const H = "─";
-// Straight end caps: with no side rails there's no vertical for a corner to turn
-// into. Set back to ╭╮╰╯ to restore the rounded box.
-const TL = H;
-const TR = H;
-const BL = H;
-const BR = H;
// Global-symbol keyed so reloads/multiple loads never double-wrap.
const ORIG = Symbol.for("sl0p.userPromptFrame.origRender");
@@ -50,42 +84,98 @@ const ORIG_INVALIDATE = Symbol.for("sl0p.userPromptFrame.origInvalidate");
// width and only recompute on width change / invalidate (theme change).
const CACHE = Symbol.for("sl0p.userPromptFrame.cache");
-function fg(hex: string, s: string): string {
+function rgb(hex: string): [number, number, number] {
const h = hex.replace("#", "");
- const r = parseInt(h.slice(0, 2), 16);
- const g = parseInt(h.slice(2, 4), 16);
- const b = parseInt(h.slice(4, 6), 16);
+ return [
+ parseInt(h.slice(0, 2), 16),
+ parseInt(h.slice(2, 4), 16),
+ parseInt(h.slice(4, 6), 16),
+ ];
+}
+
+function fg(hex: string, s: string): string {
+ const [r, g, b] = rgb(hex);
return `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
}
-function bold(s: string): string {
- return `\x1b[1m${s}\x1b[22m`;
+
+// Reverse-video chip: FRAME_HEX as background, CHIP_FG_HEX as text.
+function chipPaint(s: string): string {
+ const [br, bg_, bb] = rgb(FRAME_HEX);
+ const [fr, fgc, fb] = rgb(CHIP_FG_HEX);
+ return `\x1b[48;2;${br};${bg_};${bb}m\x1b[38;2;${fr};${fgc};${fb}m\x1b[1m${s}\x1b[22m\x1b[39m\x1b[49m`;
+}
+
+// OSC 133 (and any other OSC) markers pi prefixes onto the first/last line.
+// Matched generically so a change to the marker set degrades to "carried
+// through verbatim" instead of "silently dropped".
+// NB: the OSC payload is NOT numeric-only — pi's markers are `133;A`, `133;B`,
+// `133;C`, so the body must accept letters. Match everything up to the
+// terminator (BEL or ST) instead of a character class.
+const OSC_PREFIX_RE = /^(?:\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))+/;
+
+function splitOsc(line: string): [string, string] {
+ const m = line.match(OSC_PREFIX_RE);
+ return m ? [m[0], line.slice(m[0].length)] : ["", line];
+}
+
+// A pad line is whitespace once SGR coloring is removed (it is bg-filled).
+function isBlank(s: string): boolean {
+ // eslint-disable-next-line no-control-regex
+ return s.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
}
-function frameTop(width: number): string {
- const innerWidth = Math.max(0, width - 2);
- const leftLen = innerWidth > 2 ? 1 : 0;
- const glyph = fg(FRAME_HEX, PROMPT_GLYPH + " " + CURSOR);
- const label = LABEL ? glyph + " " + bold(fg(FRAME_HEX, LABEL)) : glyph;
- let content = ` ${label} `;
- const maxContent = Math.max(0, innerWidth - leftLen - 1);
- if (visibleWidth(content) > maxContent) content = truncateToWidth(content, maxContent, "…");
- const rightLen = Math.max(0, innerWidth - leftLen - visibleWidth(content));
- return fg(FRAME_HEX, TL + H.repeat(leftLen)) + content + fg(FRAME_HEX, H.repeat(rightLen) + TR);
+// ▐ ❯ YOU ▌──────────────────────────────────────────
+function buildHeader(width: number): string {
+ const capL = CHIP_CAPS ? CAP_L : "";
+ const capR = CHIP_CAPS ? CAP_R : "";
+ const core = CHIP_TEXT ? ` ${CHIP_TEXT} ` : " ";
+ const chipW = visibleWidth(core) + visibleWidth(capL) + visibleWidth(capR);
+ // Too narrow for the chip: degrade to a plain rule rather than overflow.
+ if (width < chipW + 2) return fg(FRAME_HEX, H.repeat(Math.max(0, width)));
+ const chip = fg(FRAME_HEX, capL) + chipPaint(core) + fg(FRAME_HEX, capR);
+ return chip + fg(FRAME_HEX, H.repeat(Math.max(0, width - chipW)));
}
-// No side rails: the zellij pane draws its own border, so per-box verticals were
-// redundant. Body lines are just indented into the box interior — same content
-// column as before, minus the rail glyph and the trailing pad/rail.
+/**
+ * `lines` is the native render at (width - rail). Strips the native blank pad
+ * lines, re-homes their OSC markers, and prefixes the rail onto each body line.
+ */
function frame(lines: string[], width: number): string[] {
- const innerWidth = Math.max(0, width - 2);
- const bodyInner = Math.max(0, width - 4); // 2-col indent + 2-col right margin
- const body = lines.map((raw) => {
- let s = raw;
- if (visibleWidth(s) > bodyInner) s = truncateToWidth(s, bodyInner, "…");
- return ` ${s}`;
- });
- const bottom = fg(FRAME_HEX, BL + H.repeat(innerWidth) + BR);
- return [ ...body, bottom];
+ const body = lines.slice();
+
+ // Leading pad line -> its OSC prefix (ZONE_START) moves onto our header.
+ let headOsc = "";
+ if (body.length > 1) {
+ const [osc, rest] = splitOsc(body[0]);
+ if (isBlank(rest)) {
+ headOsc = osc;
+ body.shift();
+ }
+ }
+
+ // Trailing pad line -> its OSC prefix (ZONE_END + ZONE_FINAL) is APPENDED to
+ // the last surviving body line. Native prefixes it, but prefixing would put
+ // the final line of real prompt text outside the prompt zone.
+ let tailOsc = "";
+ if (body.length > 1) {
+ const i = body.length - 1;
+ const [osc, rest] = splitOsc(body[i]);
+ if (isBlank(rest)) {
+ tailOsc = osc;
+ body.pop();
+ }
+ }
+
+ if (body.length === 0) return lines;
+
+ const rail = fg(FRAME_HEX, RAIL);
+ // Body lines keep their native left pad and bg fill; we only add the gutter.
+ const out = body.map((s) => rail + s);
+ out[out.length - 1] = out[out.length - 1] + tailOsc;
+
+ const result = [headOsc + buildHeader(width), ...out];
+ if (TRAILING_BLANK) result.push("");
+ return result;
}
export default function (pi: ExtensionAPI) {
@@ -102,13 +192,17 @@ export default function (pi: ExtensionAPI) {
if (!(Cls as any)[ORIG]) (Cls as any)[ORIG] = proto.render;
const orig = (Cls as any)[ORIG] as (width: number) => string[];
+ const railW = visibleWidth(RAIL);
+
proto.render = function patchedRender(this: any, width: number): string[] {
try {
if (!width || width < 8) return orig.call(this, width);
// Per-frame cache: recompute only when width changes or invalidate clears it.
const c = this[CACHE];
if (c && c.w === width) return c.lines;
- const inner = orig.call(this, width - 4); // native content at inner width
+ // Native content laid out at the width left over after the rail, so the
+ // framed block lands at exactly `width`.
+ const inner = orig.call(this, width - railW);
if (!Array.isArray(inner) || inner.length === 0) return orig.call(this, width);
const lines = frame(inner, width);
this[CACHE] = { w: width, lines };