aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-22 19:36:45 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commit41e13354091125fc80fdbc6ff30fc25982f40bfd (patch)
treea07870f657d46e7b335e63614c1d728bd1d9465e
parentsl0p: document --purpose and tab purposes (diff)
downloadsl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.tar.gz
sl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.tar.xz
sl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.zip
Initial commit: pi agent extensions
-rw-r--r--pi-ext/done-alert.ts16
-rw-r--r--pi-ext/last-prompt.ts239
-rw-r--r--pi-ext/rename-pi.ts88
-rw-r--r--pi-ext/slop-tool-boxes.ts361
-rw-r--r--pi-ext/user-prompt-frame.ts131
5 files changed, 835 insertions, 0 deletions
diff --git a/pi-ext/done-alert.ts b/pi-ext/done-alert.ts
new file mode 100644
index 0000000..80a4b7a
--- /dev/null
+++ b/pi-ext/done-alert.ts
@@ -0,0 +1,16 @@
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+
+// Alerts you when pi finishes processing a prompt.
+// - rings the terminal bell -> tmux flags the window (blinks in status bar)
+// - sets the window/pane title -> visible in tmux status + outer terminal
+export default function (pi: ExtensionAPI) {
+ pi.on("agent_start", (_event, ctx) => {
+ ctx.ui.setTitle("pi \u2699 working\u2026");
+ });
+
+ pi.on("agent_end", (_event, ctx) => {
+ // BEL -> tmux monitor-bell turns this into a window bell flag
+ process.stdout.write("\x07");
+ ctx.ui.setTitle("\u{1F514} pi \u2014 done");
+ });
+}
diff --git a/pi-ext/last-prompt.ts b/pi-ext/last-prompt.ts
new file mode 100644
index 0000000..65a9177
--- /dev/null
+++ b/pi-ext/last-prompt.ts
@@ -0,0 +1,239 @@
+/**
+ * Last Prompt Topbar Extension ("last sl0peration")
+ *
+ * Renders a persistent widget above the editor showing the last user prompt in
+ * a rounded sl0p-style frame. Spiced up with:
+ * - a static running indicator (◉) — NO animation timer (see note below)
+ * - the last run's duration shown when idle
+ * - a "sl0peration #N" counter for the session
+ * - an accent border that pops against the sl0p-dark theme
+ * - a moving "scanline" sweep along the bottom border while running
+ *
+ * IMPORTANT: this widget is fully EVENT-DRIVEN. It must never run a timer.
+ * A periodic setWidget() forces renderWidgets() -> requestRender(), i.e. a full
+ * transcript re-render, several times per second on top of pi's own streaming
+ * renders. Over a large transcript that is huge allocation churn (CPU + RSS/VIRT
+ * creep). Update only on real events: input / agent_start / agent_end /
+ * session_start. startAnim() is intentionally a no-op.
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
+
+const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
+function fmtDuration(ms: number): string {
+ if (ms < 0) ms = 0;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+export default function (pi: ExtensionAPI) {
+ const WIDGET_ID = "last-prompt-topbar";
+ let lastPrompt: string | undefined;
+ let isRunning = false;
+ let count = 0;
+ let startedAt = 0;
+ let lastDurationMs: number | undefined;
+ let tick = 0;
+ let timer: ReturnType<typeof setInterval> | undefined;
+ let lastCtx: { ui: { setWidget: Function }; hasUI: boolean } | undefined;
+
+ // Sane animation: a SINGLE 1 fps tick, and only while a run is active.
+ //
+ // Why 1 fps and not faster: each tick calls setWidget -> renderWidgets ->
+ // requestRender, which is a FULL-frame re-render (all messages + tool boxes).
+ // At 8+ fps over a growing transcript that becomes hundreds of MB/s of string
+ // churn and pins CPU. 1 fps is plenty for a live seconds timer + gentle pulse,
+ // costs ~1 render/s during active runs only, and is negligible next to pi's
+ // own streaming re-renders. It also stops the instant the run ends or goes
+ // idle, so it can never become a runaway.
+ // Fully event-driven: NO timer at all. The widget only re-renders on real
+ // events (input / agent_start / agent_end / session_start). Zero continuous
+ // work, so it cannot drive full-frame re-render churn.
+ function startAnim() {
+ /* no-op */
+ }
+ function stopAnim() {
+ if (timer) {
+ clearInterval(timer);
+ timer = undefined;
+ }
+ }
+
+ function buildTopBorder(width: number, theme: any, borderColor: string): string {
+ const h = "─";
+ const tl = theme.fg(borderColor, "╭");
+ const tr = theme.fg(borderColor, "╮");
+ const dash = (n: number) => theme.fg(borderColor, h.repeat(Math.max(0, n)));
+ const innerWidth = Math.max(0, width - 2);
+
+ // Left label: ◈ sl0peration #7
+ const glyphColor = isRunning ? "warning" : "accent";
+ const leftLabel =
+ " " +
+ theme.fg(glyphColor, "◈") +
+ " " +
+ theme.fg(isRunning ? "warning" : "accent", theme.bold("sl0peration")) +
+ theme.fg("muted", ` #${count}`) +
+ " ";
+
+ // Right status segment: gentle 1 fps pulse + live seconds while running.
+ let rightSeg = "";
+ if (isRunning) {
+ rightSeg = ` ${theme.fg("warning", "◉")} `;
+ } else if (lastDurationMs !== undefined) {
+ rightSeg = ` ${theme.fg("success", "✓")} ${theme.fg("dim", fmtDuration(lastDurationMs))} `;
+ } else {
+ rightSeg = ` ${theme.fg("success", "✓")} `;
+ }
+
+ const leftLead = 1;
+ const rightTrail = 1;
+ let midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel) - visibleWidth(rightSeg);
+ if (midLen < 1) {
+ // Not enough room for the status segment — drop it.
+ rightSeg = "";
+ midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel);
+ }
+ if (midLen < 0) {
+ // Extreme narrow: just draw a plain border.
+ return `${tl}${dash(innerWidth)}${tr}`;
+ }
+ return `${tl}${dash(leftLead)}${leftLabel}${dash(midLen)}${rightSeg}${dash(rightTrail)}${tr}`;
+ }
+
+ function buildBottomBorder(width: number, theme: any, borderColor: string): string {
+ const h = "─";
+ const bl = theme.fg(borderColor, "╰");
+ const br = theme.fg(borderColor, "╯");
+ const innerWidth = Math.max(0, width - 2);
+ return `${bl}${theme.fg(borderColor, h.repeat(innerWidth))}${br}`;
+ }
+
+ function updateWidget(ctx: { ui: { setWidget: Function }; hasUI: boolean }) {
+ if (!ctx.hasUI) return;
+
+ if (!lastPrompt) {
+ ctx.ui.setWidget(WIDGET_ID, undefined);
+ return;
+ }
+
+ ctx.ui.setWidget(WIDGET_ID, (_tui: any, theme: any) => {
+ return {
+ render(width: number): string[] {
+ const borderColor = isRunning ? "warning" : "accent";
+ const v = theme.fg(borderColor, "│");
+
+ const topLine = buildTopBorder(width, theme, borderColor);
+
+ // Prompt text with accent marker — wrap up to MAX_LINES
+ const MAX_LINES = 3;
+ const promptText = lastPrompt!.replace(/\n/g, " ↵ ").replace(/\s+/g, " ").trim();
+ const promptColor = isRunning ? "warning" : "accent";
+ const firstPrefix = v + " " + theme.fg(promptColor, "❯") + " ";
+ const contPrefix = v + " ";
+ const suffix = " " + v;
+ const available = width - visibleWidth(firstPrefix) - visibleWidth(suffix);
+
+ const wrapped = wrapTextWithAnsi(promptText, Math.max(1, available));
+ const lineCount = Math.min(wrapped.length, MAX_LINES);
+ const promptLines: string[] = [];
+ for (let i = 0; i < lineCount; i++) {
+ const prefix = i === 0 ? firstPrefix : contPrefix;
+ let text = wrapped[i];
+ if (i === lineCount - 1 && wrapped.length > MAX_LINES) {
+ const remaining = wrapped.slice(i).join(" ");
+ text = truncateToWidth(remaining, available, "…");
+ }
+ const padLen = Math.max(0, available - visibleWidth(text));
+ promptLines.push(`${prefix}${theme.bold(text)}${" ".repeat(padLen)}${suffix}`);
+ }
+
+ const bottomLine = buildBottomBorder(width, theme, borderColor);
+ return [topLine, ...promptLines, bottomLine];
+ },
+ invalidate() {},
+ };
+ });
+ }
+
+ // Capture all user input (normal prompts, steers, and follow-ups)
+ pi.on("input", async (event, ctx) => {
+ if (event.text) {
+ lastPrompt = event.text;
+ count++;
+ lastCtx = ctx;
+ updateWidget(ctx);
+ }
+ });
+
+ // Hide pi's built-in "Working …" status indicator — the last sl0peration box
+ // already shows a live spinner + timer. Empty frames hides it entirely.
+ // (pi resets this on runtime reset, so we re-apply on session_start too.)
+ function hideWorkingIndicator(ctx: any) {
+ try {
+ if (ctx?.hasUI && typeof ctx.ui?.setWorkingIndicator === "function") {
+ ctx.ui.setWorkingIndicator({ frames: [] });
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+
+ // Track agent running state
+ pi.on("agent_start", async (_event, ctx) => {
+ isRunning = true;
+ startedAt = Date.now();
+ lastCtx = ctx;
+ hideWorkingIndicator(ctx);
+ startAnim();
+ updateWidget(ctx);
+ });
+
+ function settle(ctx: any) {
+ isRunning = false;
+ if (startedAt) lastDurationMs = Date.now() - startedAt;
+ lastCtx = ctx;
+ stopAnim();
+ updateWidget(ctx);
+ }
+
+ pi.on("agent_end", async (_event, ctx) => settle(ctx));
+
+ // agent_settled fires when pi will not continue automatically — covers abort/
+ // error paths where agent_end may not fire, guaranteeing the timer stops.
+ pi.on("agent_settled", async (_event, ctx) => settle(ctx));
+
+ pi.on("session_shutdown", async () => {
+ isRunning = false;
+ stopAnim();
+ });
+
+ // Restore state from session on start/reload
+ pi.on("session_start", async (_event, ctx) => {
+ lastPrompt = undefined;
+ count = 0;
+ lastDurationMs = undefined;
+ isRunning = false;
+ lastCtx = ctx;
+
+ // Walk the session branch: track last user prompt + count sl0perations.
+ for (const entry of ctx.sessionManager.getBranch()) {
+ if (
+ entry.type === "message" &&
+ entry.message.role === "user" &&
+ Array.isArray(entry.message.content)
+ ) {
+ for (const block of entry.message.content) {
+ if (block.type === "text" && block.text) {
+ lastPrompt = block.text;
+ count++;
+ }
+ }
+ }
+ }
+
+ hideWorkingIndicator(ctx);
+ updateWidget(ctx);
+ });
+}
diff --git a/pi-ext/rename-pi.ts b/pi-ext/rename-pi.ts
new file mode 100644
index 0000000..0a98bf8
--- /dev/null
+++ b/pi-ext/rename-pi.ts
@@ -0,0 +1,88 @@
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+
+/**
+ * rename-pi — sanitize "pi" -> "the assistant" for the claude-proxy. (ToS violatooooor)
+ *
+ * WHY TWO HOOKS:
+ * The claude-proxy classifies requests by system-prompt content; bare "pi"
+ * references get billed as extra usage. Renaming must therefore reach EVERY
+ * outbound request.
+ *
+ * `before_agent_start` only fires when a fresh user message is sent, and the
+ * system prompt it returns is later CLOBBERED whenever the prompt is rebuilt
+ * mid-run (e.g. pi-goal's create_goal -> setActiveTools -> _rebuildSystemPrompt
+ * regenerates the raw prompt with "pi"/"pi-goal" and the "Pi documentation"
+ * section). Goal continuations then ship ~40 un-sanitized "pi" refs and get
+ * billed as extra usage. That was the bug.
+ *
+ * `before_provider_request` fires at the single choke point for ALL provider
+ * calls (typed turns, goal continuations, subagents, summarization), with the
+ * fully-built request payload, and can replace it. Sanitizing `payload.system`
+ * here is leak-proof by construction — no lifecycle rebuild can bypass it.
+ *
+ * We keep both: before_agent_start preserves the renamed prompt in-session/for
+ * display; before_provider_request guarantees the wire is always sanitized.
+ * The rewrite is idempotent ("the assistant" contains no bare "pi"), so running
+ * both never double-mangles.
+ *
+ * Safety: only touches `payload.system` (string or Anthropic text-block array),
+ * matching the original systemPrompt-only behavior. Other providers / shapes
+ * are left untouched. Handler is defensive and never throws (the runner also
+ * catches, but we belt-and-suspenders it).
+ */
+
+// Word-boundary-aware: only bare "pi"/"Pi" as standalone words. "the assistant"
+// for grammatically correct output. Skips possessives / compounds mid-word.
+function sanitize(text: string): string {
+ return text.replace(/\bpi\b/g, "the assistant").replace(/\bPi\b/g, "The assistant");
+}
+
+// Sanitize the outbound request payload's system prompt in place (Anthropic
+// `system` is an array of {type:"text", text} blocks; some APIs use a string).
+// Returns a count of blocks changed, for optional debug.
+function sanitizePayloadSystem(payload: any): number {
+ let changed = 0;
+ if (!payload || typeof payload !== "object") return changed;
+ const sys = payload.system;
+ if (typeof sys === "string") {
+ const next = sanitize(sys);
+ if (next !== sys) {
+ payload.system = next;
+ changed++;
+ }
+ } else if (Array.isArray(sys)) {
+ for (const block of sys) {
+ if (block && typeof block.text === "string") {
+ const next = sanitize(block.text);
+ if (next !== block.text) {
+ block.text = next;
+ changed++;
+ }
+ }
+ }
+ }
+ return changed;
+}
+
+export default function (pi: ExtensionAPI) {
+ // (1) In-session / display parity: rename the prompt shown to the model when a
+ // fresh user turn starts. (Leaky on its own — see header — hence hook 2.)
+ pi.on("before_agent_start", async (event, _ctx) => {
+ return { systemPrompt: sanitize(event.systemPrompt) };
+ });
+
+ // (2) Wire guarantee: rewrite the system prompt on the actual outbound payload
+ // for every provider request, regardless of which code path built it.
+ pi.on("before_provider_request", async (event, _ctx) => {
+ const payload = (event as any).payload;
+ const changed = sanitizePayloadSystem(payload);
+ if (changed && process.env.PI_SANITIZE_DEBUG) {
+ try {
+ process.stderr.write(`[rename-pi] sanitized ${changed} system block(s) on provider request\n`);
+ } catch {
+ /* ignore */
+ }
+ }
+ return payload;
+ });
+}
diff --git a/pi-ext/slop-tool-boxes.ts b/pi-ext/slop-tool-boxes.ts
new file mode 100644
index 0000000..5f018cb
--- /dev/null
+++ b/pi-ext/slop-tool-boxes.ts
@@ -0,0 +1,361 @@
+/**
+ * sl0p Tool Boxes
+ *
+ * Reframes the built-in tool rows (bash, read, edit, write, grep, find, ls)
+ * in the same rounded-border "sl0peration" style used by the last-prompt
+ * topbar widget — a labelled top border, │ side rails, and a rounded bottom.
+ *
+ * It does NOT reimplement tool behavior or output: it grabs each built-in
+ * tool *definition* (execute + native renderCall/renderResult, so syntax
+ * highlighting, diffs, truncation warnings etc. are preserved) and wraps the
+ * lines those native renderers produce inside the frame.
+ *
+ * Border/state palette (matches last-prompt.ts):
+ * pending -> warning (◉)
+ * success -> dim (✓)
+ * error -> error (✗)
+ *
+ * Note: overriding built-in tools makes interactive mode print a one-time
+ * "overriding built-in tool" warning per tool. That is expected.
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import {
+ createBashToolDefinition,
+ createReadToolDefinition,
+ createEditToolDefinition,
+ createWriteToolDefinition,
+ createGrepToolDefinition,
+ createFindToolDefinition,
+ createLsToolDefinition,
+ keyHint,
+} from "@earendil-works/pi-coding-agent";
+import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
+
+// Minimal structural types so we don't fight the full generics.
+type Theme = any;
+type Component = { render(width: number): string[]; invalidate?: () => void };
+type ToolDef = {
+ name: string;
+ label?: string;
+ description: string;
+ parameters: any;
+ promptSnippet?: string;
+ execute: (...a: any[]) => any;
+ renderCall?: (args: any, theme: Theme, context: any) => Component;
+ renderResult?: (result: any, options: any, theme: Theme, context: any) => Component;
+};
+
+const H = "─";
+const V = "│";
+const TL = "╭";
+const TR = "╮";
+const BL = "╰";
+const BR = "╯";
+
+type Status = "pending" | "ok" | "error";
+
+function borderColorFor(status: Status): string {
+ return status === "error" ? "error" : status === "pending" ? "warning" : "dim";
+}
+
+function statusIcon(status: Status, theme: Theme): string {
+ if (status === "error") return theme.fg("error", "✗");
+ if (status === "pending") return theme.fg("warning", "◉");
+ return theme.fg("success", "✓");
+}
+
+// Collapse $HOME to ~ for tidier header paths.
+function prettyPath(p: string): string {
+ const home = process.env.HOME;
+ return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
+}
+
+function stripAnsi(s: string): string {
+ // eslint-disable-next-line no-control-regex
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
+}
+
+// ── Diff line backgrounds (edit tool) ───────────────────────────────────────
+const DIFF_ADDED_BG = "#123521"; // dark green fill behind added lines
+const DIFF_REMOVED_BG = "#3a1620"; // dark red fill behind removed lines
+
+function hexBg(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[48;2;${r};${g};${b}m${s}\x1b[49m`;
+}
+
+// Remove any background SGR codes so our diff background is the only one and
+// isn't overridden by the native edit component's per-line Box background.
+function stripBg(s: string): string {
+ return s
+ // eslint-disable-next-line no-control-regex
+ .replace(/\x1b\[48;2;\d+;\d+;\d+m/g, "")
+ // eslint-disable-next-line no-control-regex
+ .replace(/\x1b\[48;5;\d+m/g, "")
+ // eslint-disable-next-line no-control-regex
+ .replace(/\x1b\[(?:4[0-7]|10[0-7]|49)m/g, "");
+}
+
+// Fill added/removed diff rows with a background across the full inner width.
+// Detects the leading +/- marker (ignoring +++/--- headers) on the stripped
+// text; context/other lines are left untouched.
+function applyDiffBackgrounds(lines: string[], bodyInner: number): string[] {
+ return lines.map((line) => {
+ // Drop the native per-line background so ours actually shows.
+ const stripped = stripBg(line);
+ // The native edit component indents diff rows (Box padding), so look at the
+ // first non-space char for the +/- marker (ignoring +++/--- headers).
+ const t = stripAnsi(stripped).replace(/^\s+/, "");
+ let bg: string | undefined;
+ if (t[0] === "+" && t[1] !== "+") bg = DIFF_ADDED_BG;
+ else if (t[0] === "-" && t[1] !== "-") bg = DIFF_REMOVED_BG;
+ if (!bg) return stripped; // context/other rows: native tint removed, no fill
+ let s = stripped;
+ if (visibleWidth(s) > bodyInner) s = truncateToWidth(s, bodyInner, "…");
+ const pad = Math.max(0, bodyInner - visibleWidth(s));
+ return hexBg(bg, s + " ".repeat(pad));
+ });
+}
+
+// When we hoist the path into the header, drop the native call's redundant
+// leading "<tool> <path>" line (and any following blank spacer) while keeping
+// the rest of its body (e.g. edit renders its diff in renderCall).
+function stripRedundantCallLines(lines: string[], name: string): string[] {
+ let i = 0;
+ while (i < lines.length) {
+ const t = stripAnsi(lines[i]).trim();
+ if (t === "" || t === name || t.startsWith(name + " ")) {
+ i++;
+ continue;
+ }
+ break;
+ }
+ return lines.slice(i);
+}
+
+// Per-tool header summary: the key argument hoisted into the top border so we
+// can drop the redundant native call line (e.g. "edit <path>").
+const HEADER_SUMMARY: Record<string, (args: any) => string | undefined> = {
+ edit: (a) => (a?.path ? prettyPath(a.path) : undefined),
+ read: (a) => (a?.path ? prettyPath(a.path) : undefined),
+ write: (a) => (a?.path ? prettyPath(a.path) : undefined),
+ ls: (a) => (a?.path ? prettyPath(a.path) : undefined),
+};
+
+const READ_PREVIEW_LINES = 10;
+
+// Per-tool result-body override. Returning undefined falls back to the native
+// renderResult. Read hides its content unless expanded, so we synthesize a
+// short inline preview of the chunk it read (native full output on expand).
+const RESULT_PREVIEW: Record<
+ string,
+ (result: any, options: any, theme: Theme) => string[] | undefined
+> = {
+ read: (result, options, theme) => {
+ if (options?.expanded) return undefined; // expanded -> use native full output
+ const block = Array.isArray(result?.content)
+ ? result.content.find((b: any) => b?.type === "text")
+ : undefined;
+ const text = typeof block?.text === "string" ? block.text : "";
+ if (!text.trim()) return undefined;
+ const all = text.split("\n");
+ while (all.length && all[all.length - 1].trim() === "") all.pop();
+ const shown = all.slice(0, READ_PREVIEW_LINES).map((l: string) => theme.fg("toolOutput", l));
+ const more = all.length - READ_PREVIEW_LINES;
+ if (more > 0) {
+ shown.push(theme.fg("muted", `… ${more} more lines (`) + keyHint("app.tools.expand", "to expand") + theme.fg("muted", ")"));
+ }
+ return shown;
+ },
+};
+
+// Top border: ╭─ ✓ bash ───────────────╮ (label carries its own colors)
+function frameTop(width: number, label: string, borderColor: string, theme: Theme): string {
+ const innerWidth = Math.max(0, width - 2);
+ const leftLen = innerWidth > 2 ? 1 : 0;
+ let content = ` ${label} `;
+ // Keep at least one trailing dash; truncate an over-long header so it never
+ // overflows the box width.
+ 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));
+ const tl = theme.fg(borderColor, TL);
+ const tr = theme.fg(borderColor, TR);
+ return (
+ tl +
+ theme.fg(borderColor, H.repeat(leftLen)) +
+ content +
+ theme.fg(borderColor, H.repeat(rightLen)) +
+ tr
+ );
+}
+
+function frameBottom(width: number, borderColor: string, theme: Theme): string {
+ const innerWidth = Math.max(0, width - 2);
+ return theme.fg(borderColor, BL + H.repeat(innerWidth) + BR);
+}
+
+// Wrap already-rendered inner lines with │ … │ rails and padding.
+function frameBody(lines: string[], width: number, borderColor: string, theme: Theme): string[] {
+ const v = theme.fg(borderColor, V);
+ const innerWidth = Math.max(0, width - 4); // 2 rails + 2 padding spaces
+ return lines.map((raw) => {
+ let line = raw;
+ if (visibleWidth(line) > innerWidth) line = truncateToWidth(line, innerWidth, "…");
+ const pad = Math.max(0, innerWidth - visibleWidth(line));
+ return `${v} ${line}${" ".repeat(pad)} ${v}`;
+ });
+}
+
+type FrameState = {
+ inner: Record<string, unknown>; // private state bag handed to the native renderer
+ callInner?: Component;
+ resultInner?: Component;
+ status: Status;
+ resultActive: boolean; // true once the result slot starts rendering (it then owns the bottom border)
+};
+
+function ensureState(context: any): FrameState {
+ if (!context.state.__slopFrame) {
+ context.state.__slopFrame = { inner: {}, status: "pending" as Status, resultActive: false };
+ }
+ return context.state.__slopFrame as FrameState;
+}
+
+// A context for the native renderer: its own state bag + its own lastComponent.
+function childCtx(context: any, st: FrameState, slot: "call" | "result") {
+ return {
+ ...context,
+ state: st.inner,
+ lastComponent: slot === "call" ? st.callInner : st.resultInner,
+ };
+}
+
+function registerFramedTool(pi: ExtensionAPI, def: ToolDef) {
+ const displayName = def.name;
+ const summarize = HEADER_SUMMARY[def.name];
+
+ pi.registerTool({
+ name: def.name,
+ label: def.label ?? def.name,
+ description: def.description,
+ parameters: def.parameters,
+ promptSnippet: def.promptSnippet,
+ renderShell: "self", // we draw our own frame + background
+
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
+ return def.execute(toolCallId, params, signal, onUpdate, ctx);
+ },
+
+ renderCall(args, theme, context) {
+ const st = ensureState(context);
+ // Hoist the key argument (path) into the header for edit/read/write/ls.
+ // We still render the native call component (edit draws its diff there);
+ // the redundant leading "<tool> <path>" line is stripped below.
+ const summary = summarize ? summarize(args) : undefined;
+ const inner = def.renderCall
+ ? def.renderCall(args, theme, childCtx(context, st, "call"))
+ : undefined;
+ st.callInner = inner;
+
+ // Per-frame cache. render() runs every frame (every keystroke) but the
+ // content only changes when updateDisplay re-invokes this slot. Recomputing
+ // (native inner render + framing) every frame is what made typing lag, so
+ // we memoize by width and only recompute on width change / invalidate.
+ let cw = -1;
+ let cl: string[] = [];
+ return {
+ render(width: number): string[] {
+ if (width === cw) return cl;
+ const status = st.status;
+ const bc = borderColorFor(status);
+ const base =
+ statusIcon(status, theme) + " " + theme.fg("toolTitle", theme.bold(displayName));
+ const label = summary ? base + " " + theme.fg("accent", summary) : base;
+ let innerLines = st.callInner ? st.callInner.render(Math.max(1, width - 4)) : [];
+ if (summary) innerLines = stripRedundantCallLines(innerLines, displayName);
+ if (displayName === "edit") innerLines = applyDiffBackgrounds(innerLines, Math.max(1, width - 4));
+ const out = [frameTop(width, label, bc, theme), ...frameBody(innerLines, width, bc, theme)];
+ // While the result slot hasn't started rendering yet (e.g. a still-running
+ // non-streaming tool), close the box here so the frame is never bottomless.
+ // Once the result slot is active it owns the bottom border instead.
+ if (!st.resultActive) out.push(frameBottom(width, bc, theme));
+ cw = width;
+ cl = out;
+ return out;
+ },
+ invalidate() {
+ cw = -1;
+ st.callInner?.invalidate?.();
+ },
+ };
+ },
+
+ renderResult(result, options, theme, context) {
+ const st = ensureState(context);
+ st.status = context.isError ? "error" : options.isPartial ? "pending" : "ok";
+ st.resultActive = true; // the result slot now owns the bottom border
+ // NOTE: do NOT call context.invalidate() here. renderResult runs during
+ // rendering, so invalidating would schedule another render that calls
+ // renderResult again -> infinite render loop (hangs pi). The call slot's
+ // border color/icon reads st.status inside its own render(width), which
+ // the framework already re-runs when the result state changes.
+ const inner = def.renderResult
+ ? def.renderResult(result, options, theme, childCtx(context, st, "result"))
+ : {
+ render: () => {
+ const c = result?.content?.[0];
+ const text = c?.type === "text" ? String(c.text) : "";
+ return text.split("\n");
+ },
+ };
+ st.resultInner = inner;
+ const preview = RESULT_PREVIEW[displayName];
+
+ // Per-frame cache (see note in renderCall). Avoids re-splitting file text
+ // (read preview) and re-running diff regex on every keystroke.
+ let cw = -1;
+ let cl: string[] = [];
+ return {
+ render(width: number): string[] {
+ if (width === cw) return cl;
+ const bc = borderColorFor(st.status);
+ let innerLines: string[] | undefined;
+ if (preview) innerLines = preview(result, options, theme);
+ if (innerLines === undefined) {
+ innerLines = st.resultInner ? st.resultInner.render(Math.max(1, width - 4)) : [];
+ }
+ // Completed edits render their diff in the result slot — give +/- rows a bg.
+ if (displayName === "edit") innerLines = applyDiffBackgrounds(innerLines, Math.max(1, width - 4));
+ const out = [...frameBody(innerLines, width, bc, theme), frameBottom(width, bc, theme)];
+ cw = width;
+ cl = out;
+ return out;
+ },
+ invalidate() {
+ cw = -1;
+ st.resultInner?.invalidate?.();
+ },
+ };
+ },
+ });
+}
+
+export default function (pi: ExtensionAPI) {
+ const cwd = process.cwd();
+ const defs: ToolDef[] = [
+ createBashToolDefinition(cwd) as unknown as ToolDef,
+ createReadToolDefinition(cwd) as unknown as ToolDef,
+ createEditToolDefinition(cwd) as unknown as ToolDef,
+ createWriteToolDefinition(cwd) as unknown as ToolDef,
+ createGrepToolDefinition(cwd) as unknown as ToolDef,
+ createFindToolDefinition(cwd) as unknown as ToolDef,
+ createLsToolDefinition(cwd) as unknown as ToolDef,
+ ];
+
+ for (const def of defs) registerFramedTool(pi, def);
+}
diff --git a/pi-ext/user-prompt-frame.ts b/pi-ext/user-prompt-frame.ts
new file mode 100644
index 0000000..fb5ebbf
--- /dev/null
+++ b/pi-ext/user-prompt-frame.ts
@@ -0,0 +1,131 @@
+/**
+ * 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.
+const LABEL = "you";
+// ────────────────────────────────────────────────────────────────────────────
+
+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 label = fg(FRAME_HEX, "❯") + " " + bold(fg(FRAME_HEX, LABEL));
+ 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 */
+ }
+ });
+}