diff options
| author | user <user@clank> | 2026-07-22 19:36:45 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | 41e13354091125fc80fdbc6ff30fc25982f40bfd (patch) | |
| tree | a07870f657d46e7b335e63614c1d728bd1d9465e | |
| parent | sl0p: document --purpose and tab purposes (diff) | |
| download | sl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.tar.gz sl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.tar.xz sl0ppi-41e13354091125fc80fdbc6ff30fc25982f40bfd.zip | |
Initial commit: pi agent extensions
| -rw-r--r-- | pi-ext/done-alert.ts | 16 | ||||
| -rw-r--r-- | pi-ext/last-prompt.ts | 239 | ||||
| -rw-r--r-- | pi-ext/rename-pi.ts | 88 | ||||
| -rw-r--r-- | pi-ext/slop-tool-boxes.ts | 361 | ||||
| -rw-r--r-- | pi-ext/user-prompt-frame.ts | 131 |
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 */ + } + }); +} |
