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
|
/**
* 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
* internals and patches `UserMessageComponent.prototype.render` at load time.
*
* 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.
*
* 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.
*
* 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 { truncateToWidth, 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 H = "─";
const V = "│";
const TL = "╭";
const TR = "╮";
const BL = "╰";
const BR = "╯";
// 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 fg(hex: string, s: string): string {
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 `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
}
function bold(s: string): string {
return `\x1b[1m${s}\x1b[22m`;
}
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);
}
function frame(lines: string[], width: number): string[] {
const innerWidth = Math.max(0, width - 2);
const bodyInner = Math.max(0, width - 4); // rails (2) + 1 space padding per side
const v = fg(FRAME_HEX, V);
const body = lines.map((raw) => {
let s = raw;
if (visibleWidth(s) > bodyInner) s = truncateToWidth(s, bodyInner, "…");
const pad = Math.max(0, bodyInner - visibleWidth(s));
return `${v} ${s}${" ".repeat(pad)} ${v}`;
});
const bottom = fg(FRAME_HEX, BL + H.repeat(innerWidth) + BR);
return [frameTop(width), ...body, bottom];
}
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[];
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
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 */
}
});
}
|