/** * User Prompt Frame (monkeypatch) * * 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. * * 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. * * 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) * - feature-detects render; silently no-ops if internals change * - patched render try/catches and falls back to the native render on error */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { UserMessageComponent } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; // ── Style knobs ───────────────────────────────────────────────────────────── 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 = "─"; // Global-symbol keyed so reloads/multiple loads never double-wrap. const ORIG = Symbol.for("sl0p.userPromptFrame.origRender"); const ORIG_INVALIDATE = Symbol.for("sl0p.userPromptFrame.origInvalidate"); // Per-instance render cache. render() runs every frame (every keystroke); the // framed output only depends on width (prompt text is immutable), so memoize by // width and only recompute on width change / invalidate (theme change). const CACHE = Symbol.for("sl0p.userPromptFrame.cache"); function rgb(hex: string): [number, number, number] { const h = hex.replace("#", ""); 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`; } // 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() === ""; } // ▐ ❯ 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))); } /** * `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 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) { const Cls = UserMessageComponent as unknown as { prototype: { render?: (width: number) => string[] }; [ORIG]?: (width: number) => string[]; }; const proto: any = Cls.prototype; if (!proto || typeof proto.render !== "function") return; // Capture the true original once; always re-wrap from it so a /reload picks // up new framing code without stacking wrappers. 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; // 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 }; return lines; } catch { return orig.call(this, width); } }; // Clear our cache whenever the component is invalidated (e.g. theme change), // then run the original invalidate (defined up the prototype chain). if (!(Cls as any)[ORIG_INVALIDATE]) (Cls as any)[ORIG_INVALIDATE] = proto.invalidate; const origInvalidate = (Cls as any)[ORIG_INVALIDATE]; proto.invalidate = function patchedInvalidate(this: any, ...args: any[]) { this[CACHE] = undefined; if (typeof origInvalidate === "function") return origInvalidate.apply(this, args); }; // Restore native rendering if this extension instance is torn down. pi.on("session_shutdown", () => { try { if ((Cls as any)[ORIG]) proto.render = (Cls as any)[ORIG]; if ((Cls as any)[ORIG_INVALIDATE]) proto.invalidate = (Cls as any)[ORIG_INVALIDATE]; } catch { /* ignore */ } }); }