Files
dotfiles/pi/.pi/agent/extensions/prompt-frame.ts
2026-08-04 11:48:23 +02:00

218 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { CustomEditor } from "@earendil-works/pi-coding-agent";
import { splitBorder, frameLine } from "./lib/boxes.ts";
/**
* prompt-frame: Wraps the prompt (editor) in the shared rounded-box border
* and embeds the footer into the box's bottom border, tool-block style:
*
* ╭──────────────────────────────────────────────────────────╮
* │ type here… │
* ╰─ glm-5.2 · high · ctx 12% ──── S: 53% 󰪢 W: 21% 󰪟 M: 66% 󰪣 ─╯
*
* Footer contents:
* left: current model id · thinking level · context percent
* right: opencode usage from the usage-bars extension, formatted as
* "S: 53% <glyph>" (5h) / "W: …" (weekly) / "M: …" (monthly),
* where <glyph> is a nerd-font circle-slice progress icon.
*
* The default footer is hidden (its factory is still used to capture the
* FooterDataProvider so we can read usage-bars' status string).
* The box border color keeps pi's thinking-level / bash-mode signal.
*
* ⚠ pi-internals coupling — re-check after a pi upgrade:
* This extension reverse-engineers the Editor's rendered line layout, which
* has NO public hook. If pi changes Editor.render(), this breaks silently.
* - Editor.render() returns: [topBorder, ...contentLines, bottomBorder,
* ...autocompleteLines]. The @/#/slash autocomplete dropdown is appended
* AFTER the bottom border (this is what the bottomIdx scan relies on).
* - The editor's top/bottom border is `borderColor("─").repeat(width)` (a
* full rule) or a scroll indicator `─── ↑/↓ N more ───`. isBorder()
* matches exactly these two shapes to find the bottom border.
* - CustomEditor constructor signature (tui, theme, keybindings, options)
* and the `{ paddingX: 0 }` option; `this.borderColor` on the instance.
* - Editor.setPaddingX() is overridden to a no-op so pi's `editorPaddingX`
* setting can't double-indent the framed content (see the override below).
* If pi ever renames that method, the frame just gains stray inner padding.
* - ctx.ui.setEditorComponent / ctx.ui.setFooter factory contracts, and
* footerData.getExtensionStatuses() returning the usage-bars status.
* - usage-bars status string shape `S ████░░░░ 53% …` (parseUsage regex).
* Performance contract: pi re-renders the whole tree up to ~60fps; keep this
* component's render O(editor lines) — do NOT add per-frame heavy work here.
*/
const USAGE_STATUS_KEY = "usage-bars";
const QWEN_STATUS_KEY = "qwencloud-usage";
/** Nerd-font circle-slice progress glyphs (nf-md-circle_slice_1..8). */
const SLICES = ["\u{F0A9E}", "\u{F0A9F}", "\u{F0AA0}", "\u{F0AA1}", "\u{F0AA2}", "\u{F0AA3}", "\u{F0AA4}", "\u{F0AA5}"];
function sliceGlyph(percent: number) {
const idx = Math.min(8, Math.max(1, Math.ceil((percent / 100) * 8)));
return SLICES[idx - 1];
}
function usageColor(percent: number) {
if (percent >= 90) return "error";
if (percent >= 70) return "warning";
return "success";
}
/** `S: 53% 󰪢` — percent + glyph colored by usage level. */
function usageSegment(theme: any, label: string, percent: number) {
// const color = usageColor(percent);
return theme.fg("muted", `${label}: `) + theme.fg("muted", `${percent}% ${sliceGlyph(percent)}`);
}
const ANSI_RE = /\x1b\[[0-9;]*m/g;
/**
* Parse S/W/M percentages out of usage-bars' footer status string
* (e.g. "OpenCode Go S ████░░░░ 53% ⟳ 2h W ██░░░░░░ 21% M ███░░░░░ 66%").
*/
function parseUsage(status: string) {
const plain = status.replace(ANSI_RE, "");
const grab = (label: string) => {
const m = plain.match(new RegExp(`\\b${label}\\s+[█░]*\\s*(\\d+(?:\\.\\d+)?)%`));
return m ? Math.round(parseFloat(m[1])) : undefined;
};
return { session: grab("S"), weekly: grab("W"), monthly: grab("M") };
}
class HiddenFooter {
render() {
return [];
}
invalidate() {}
}
export default function (pi: any) {
let activeTui: any;
let footerData: any;
// usage-bars emits this after every poll - keep the border fresh.
pi.events?.on?.("usage:update", () => activeTui?.requestRender());
pi.on("session_shutdown", () => {
activeTui = undefined;
});
pi.on("session_start", (_event: any, ctx: any) => {
// Hide the default footer, but keep a handle on its data provider so
// we can read extension statuses (usage-bars).
ctx.ui.setFooter((_tui: any, _theme: any, data: any) => {
footerData = data;
return new HiddenFooter();
});
const footerLeft = (theme: any) => {
const model = ctx.model?.id ?? "no model";
const thinking = pi.getThinkingLevel();
const usage = ctx.getContextUsage();
const ctxPct = usage?.percent != null ? `${Math.round(usage.percent)}%` : "?";
return (
theme.fg("text", model) +
theme.fg("muted", " · ") +
theme.fg("dim", String(thinking)) +
theme.fg("muted", " · ") +
theme.fg("dim", `ctx ${ctxPct}`)
);
};
const footerRight = (theme: any) => {
const statuses = footerData?.getExtensionStatuses();
// 1. usage-bars (Codex, Claude, Z.AI, Gemini, etc.)
const usageStatus = statuses?.get(USAGE_STATUS_KEY);
if (usageStatus) {
const { session, weekly, monthly } = parseUsage(usageStatus);
const parts: string[] = [];
if (session !== undefined) parts.push(usageSegment(theme, "s", session));
if (weekly !== undefined) parts.push(usageSegment(theme, "w", weekly));
if (monthly !== undefined) parts.push(usageSegment(theme, "m", monthly));
if (parts.length === 0) {
return theme.fg("dim", usageStatus.replace(ANSI_RE, ""));
}
return parts.join(" ");
}
// 2. qwencloud-usage (pre-formatted cost string with theme colours)
const qwenStatus = statuses?.get(QWEN_STATUS_KEY);
if (qwenStatus) return qwenStatus;
return "";
};
class FramedEditor extends CustomEditor {
constructor(tui: any, theme: any, keybindings: any) {
super(tui, theme, keybindings, { paddingX: 0 });
activeTui = tui;
}
/**
* The frame supplies its own horizontal padding (frameLine adds "│ " / " │"),
* so the editor itself must stay at paddingX 0. pi pushes the global
* `editorPaddingX` setting onto whatever editor is active - on /settings
* change AND on every settings reload (interactive-mode calls
* `this.editor.setPaddingX?.(editorPaddingX)`) - which would indent the
* text a second time inside the box. Ignore those pushes.
* Note: `outputPad` (user/assistant/thinking message padding) is a
* separate setting and does not reach the editor at all.
*/
setPaddingX(_padding: number) {}
render(width: number) {
const border = (s: string) => this.borderColor(s);
const inner = super.render(Math.max(10, width - 4));
if (inner.length < 2) return inner;
const theme = ctx.ui.theme;
const plain = (s: string) => s.replace(ANSI_RE, "");
// The editor's own first/last lines are plain rules, or scroll
// indicators like "─── ↑ 2 more ───" - carry those into our border.
const scrollInfo = (line: string) => {
const m = plain(line).match(/([↑↓] \d+ more)/);
return m ? theme.fg("dim", m[1]) : "";
};
// The editor renders: [topBorder, ...contentLines, bottomBorder, ...autocompleteLines].
// When the @/#/slash autocomplete dropdown is open its items are appended
// *after* the editor's bottom border. Locate that border (the last line
// that is a full rule or a scroll indicator) so we frame the content and
// the dropdown correctly instead of mistaking the last dropdown item for
// the border (which dropped it and leaked a stray rule into the box).
const isBorder = (line: string) => {
const p = plain(line);
return (p.includes("─") && /^─+\s*$/.test(p)) || /[↑↓] \d+ more/.test(p);
};
let bottomIdx = inner.length - 1;
for (let i = inner.length - 1; i >= 1; i--) {
if (isBorder(inner[i])) {
bottomIdx = i;
break;
}
}
const top = splitBorder(width, scrollInfo(inner[0]), "", border, ["╭", "╮"]);
const bottom = splitBorder(
width,
scrollInfo(inner[bottomIdx]) || footerLeft(theme),
footerRight(theme),
border,
["╰", "╯"],
);
const out = [top];
// Content lines: between the top border and the editor's own bottom border.
for (let i = 1; i < bottomIdx; i++) out.push(frameLine(width, inner[i], border));
// Autocomplete dropdown (after the editor's bottom border): keep it inside
// the box, above the footer. Skip inner[bottomIdx] itself - the editor's
// plain rule, which our box border already replaces.
for (let i = bottomIdx + 1; i < inner.length; i++) out.push(frameLine(width, inner[i], border));
out.push(bottom);
return out;
}
}
ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => new FramedEditor(tui, theme, keybindings));
});
}