diff options
| author | user <user@clank> | 2026-08-11 22:32:15 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | 0c9022158ee3d311c819230bc3ef0a425f6d97a5 (patch) | |
| tree | e2756aee2aea5e8c83703d05816a8c4e51b518c1 | |
| parent | input-frame: remove header rule and rails, plain separator (diff) | |
| download | sl0ppi-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.ts | 198 |
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 }; |
