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
|
/**
* 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 */
}
});
}
|