218 lines
9.3 KiB
TypeScript
218 lines
9.3 KiB
TypeScript
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));
|
||
});
|
||
}
|