aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-23 00:52:40 +0200
committeruser <user@clank>2026-08-12 19:34:35 +0200
commit802eade8b4cb04e0f730237ee773254f6cf5cea5 (patch)
tree6c79acd5b4e87869dd40ea5bd2e81a21380575e6
parentinput-frame: wrap the input editor in a real rounded box (diff)
downloadsl0ppi-802eade8b4cb04e0f730237ee773254f6cf5cea5.tar.gz
sl0ppi-802eade8b4cb04e0f730237ee773254f6cf5cea5.tar.xz
sl0ppi-802eade8b4cb04e0f730237ee773254f6cf5cea5.zip
sl0p-footer + attached input box
Add sl0p-footer.ts: replace the built-in 2-3 line footer with one dense single line via ctx.ui.setFooter — ↑/↓ tokens, context %/window (pressure colored), cwd (+git branch), and model + thinking effort right-aligned. Data via the live extension context (sessionManager/model/getContextUsage) and footerData; repaints on branch/model/thinking changes. Make the footer look attached to the input box: - input-frame.ts now ends its box in a divider (├──┤) instead of a closing corner, so the footer reads as a continuation. - the footer wraps its line in matching side rails + the real closing bottom (╰──╯), content inset to align with the input. - footer frame is colored with theme.getThinkingBorderColor(level), tracked via thinking_level_select (seeded from the branch/model default), so the seam matches the input box border at every thinking level. Bash-mode border color is the one unmirrored case (editor state not exposed to extensions). Two stacked components styled to look like one box — no cross-component theme/state sharing, and the autocomplete menu is left to render between them.
-rw-r--r--pi-ext/input-frame.ts17
-rw-r--r--pi-ext/sl0p-footer.ts198
2 files changed, 208 insertions, 7 deletions
diff --git a/pi-ext/input-frame.ts b/pi-ext/input-frame.ts
index b0622d5..7227cb7 100644
--- a/pi-ext/input-frame.ts
+++ b/pi-ext/input-frame.ts
@@ -13,11 +13,12 @@
* selector editors keep the plain native chrome.
*
* We render content at a reduced inner width and wrap it in a rounded, side-
- * railed box that matches the sl0p tool/prompt boxes:
+ * railed box that matches the sl0p tool/prompt boxes. The BOTTOM is a divider
+ * (├──┤) rather than a closing corner, so the sl0p-footer directly below (which
+ * draws matching side rails + the real ╰──╯) reads as one attached box:
* ╭─ ❯ ───────────────╮
* │ your input text │
- * │ second line │
- * ╰────────────────────╯
+ * ├────────────────────┤ ← divider; sl0p-footer continues below
* /slash menu below, borderless, indented to sit under the rail interior
*
* Correctness notes:
@@ -52,8 +53,10 @@ const H = "─";
const V = "│";
const TL = "╭";
const TR = "╮";
-const BL = "╰";
-const BR = "╯";
+// The input box's bottom is a divider (tee junctions), not closing corners, so
+// the footer beneath it looks like a continuation of the same box.
+const ML = "├";
+const MR = "┤";
const ORIG = Symbol.for("sl0p.inputFrame.origRender");
@@ -89,8 +92,8 @@ function borderLabel(line: string, kind: "top" | "bottom"): string | undefined {
// Build a full-width rounded border line, optionally with a left-anchored label.
function buildBorder(width: number, kind: "top" | "bottom", label: string | undefined, paint: Paint): string {
- const L = kind === "top" ? TL : BL;
- const R = kind === "top" ? TR : BR;
+ const L = kind === "top" ? TL : ML;
+ const R = kind === "top" ? TR : MR;
const inner = Math.max(0, width - 2);
if (!label) return paint(L + H.repeat(inner) + R);
const leftLen = 1;
diff --git a/pi-ext/sl0p-footer.ts b/pi-ext/sl0p-footer.ts
new file mode 100644
index 0000000..075edeb
--- /dev/null
+++ b/pi-ext/sl0p-footer.ts
@@ -0,0 +1,198 @@
+/**
+ * sl0p Footer — dense, single-line replacement for pi's built-in footer.
+ *
+ * pi's native footer is 2–3 dim lines (pwd / stats / extension-status). This
+ * replaces it entirely (ctx.ui.setFooter) with ONE dense line, wrapped in side
+ * rails + a closing bottom border so it looks ATTACHED to the input box above
+ * (input-frame.ts ends in a ├──┤ divider instead of a closing corner):
+ *
+ * ├─────────────────────────────────────┤ ← input box divider
+ * │ ↑12k ↓3.4k 38%/200k ~/dev/pi-ext (main) glm-4.6 high │
+ * ╰─────────────────────────────────────╯
+ *
+ * The frame uses `borderMuted` (matches the input box at rest; the input box
+ * border shifts with thinking/bash mode, so the seam only diverges in color
+ * there — intentionally not synced, to keep the two components decoupled).
+ *
+ * Metrics sit on the left (most important, kept when narrow), the path/branch
+ * cluster is truncated first, and model + thinking effort stay right-aligned.
+ *
+ * Data sources (all live — the extension context uses call-time getters):
+ * - token totals: ctx.sessionManager.getEntries() (cumulative, matches native)
+ * - context %: ctx.getContextUsage()
+ * - model: ctx.model
+ * - thinking: last thinking_level_change on the branch, else model.reasoning
+ * - git branch: footerData.getGitBranch()
+ *
+ * Note: this intentionally drops the native extension-status line (setStatus)
+ * to stay single-line. Say the word and I'll fold statuses in (truncatable).
+ */
+
+import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
+
+// ── sl0p palette (truecolor) ────────────────────────────────────────────────
+const C = {
+ cyan: "#2ed6ff", // input ↑
+ green: "#7ee787", // output ↓ / low context
+ violet: "#b05cff", // cache R/W, thinking, unknown context
+ amber: "#ff8a1e", // cost, mid context
+ pink: "#ff2e88", // model
+ red: "#ff5c5c", // high context
+ branch: "#8ac6a0", // git branch
+};
+
+// Current thinking effort, tracked so the footer frame color matches the input
+// box border (which pi recolors by thinking level). Updated via the same
+// thinking_level_select event that drives the input box, seeded on apply.
+let thinkLevel = "off";
+
+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 fmtTok(n: number): string {
+ if (n < 1000) return String(n);
+ if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 1 : 0) + "k";
+ return (n / 1_000_000).toFixed(1) + "M";
+}
+
+function prettyPath(p: string): string {
+ const home = process.env.HOME || process.env.USERPROFILE;
+ return home && p.startsWith(home) ? "~" + p.slice(home.length) : p;
+}
+
+function addUsage(t: Usage, u: Usage | undefined): void {
+ if (!u) return;
+ t.input += u.input;
+ t.output += u.output;
+ t.cacheRead += u.cacheRead;
+ t.cacheWrite += u.cacheWrite;
+ t.cost.total += u.cost.total;
+}
+
+// Current thinking level = last thinking_level_change on the active branch,
+// falling back to the model's default reasoning effort (undefined if the model
+// doesn't support reasoning at all).
+function currentThinking(ctx: any): string | undefined {
+ const branch = ctx.sessionManager.getBranch();
+ for (let i = branch.length - 1; i >= 0; i--) {
+ if (branch[i].type === "thinking_level_change") return branch[i].thinkingLevel;
+ }
+ return ctx.model?.reasoning;
+}
+
+function renderFooter(width: number, ctx: any, theme: any, footerData: any): string[] {
+ const dim = (s: string) => theme.fg("dim", s);
+
+ // ── token burn (cumulative over all entries, like the native footer) ──
+ const t: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } as Usage;
+ for (const e of ctx.sessionManager.getEntries() as any[]) {
+ if (e.type === "message" && e.message.role === "assistant") addUsage(t, (e.message as AssistantMessage).usage);
+ else if (e.type === "message" && e.message.role === "toolResult" && e.message.usage) addUsage(t, e.message.usage);
+ else if ((e.type === "branch_summary" || e.type === "compaction") && e.usage) addUsage(t, e.usage);
+ }
+ const tok: string[] = [
+ fg(C.cyan, "↑") + dim(fmtTok(t.input)),
+ fg(C.green, "↓") + dim(fmtTok(t.output)),
+ ];
+
+ // ── context usage ──
+ const cu = ctx.getContextUsage?.();
+ const pct: number | null = cu?.percent ?? null;
+ const win = fmtTok(cu?.contextWindow ?? ctx.model?.contextWindow ?? 0);
+ const ctxColor = pct == null ? C.violet : pct > 90 ? C.red : pct > 70 ? C.amber : C.green;
+ const ctxSeg = fg(ctxColor, (pct == null ? "?" : pct.toFixed(0)) + "%") + dim("/" + win);
+
+ // ── path + branch ──
+ const branch = footerData?.getGitBranch?.();
+ const pathSeg = dim(prettyPath(ctx.sessionManager.getCwd())) + (branch ? " " + dim("(") + fg(C.branch, branch) + dim(")") : "");
+
+ // ── model + thinking effort (right-aligned) ──
+ const model = ctx.model?.id || "no-model";
+ const supportsThinking = !!ctx.model?.reasoning; // gate: does this model reason?
+ const thinkSeg = !supportsThinking ? "" : thinkLevel === "off" ? dim("off") : fg(C.violet, thinkLevel);
+ const right = fg(C.pink, model) + (thinkSeg ? " " + thinkSeg : "");
+
+ const left = [tok.join(" "), ctxSeg, pathSeg].join(" ");
+
+ // Compose the dense line at the INNER width (inside the rails), metrics left /
+ // model right; truncate the left (path first) if tight.
+ const inner = Math.max(1, width - 4); // 2 rails + 1 space of padding each side
+ const lw = visibleWidth(left);
+ const rw = visibleWidth(right);
+ let content: string;
+ if (lw + 2 + rw <= inner) {
+ content = left + " ".repeat(inner - lw - rw) + right;
+ } else {
+ const avail = Math.max(0, inner - rw - 1);
+ const leftT = truncateToWidth(left, avail, dim("…"));
+ const pad = Math.max(1, inner - visibleWidth(leftT) - rw);
+ content = truncateToWidth(leftT + " ".repeat(pad) + right, inner);
+ }
+
+ // Wrap in side rails + a closing bottom border. Color it with the SAME
+ // thinking-level border color the input box uses, so the seam under the
+ // divider matches (falls back to borderMuted if the theme lacks the helper).
+ const border: (s: string) => string =
+ typeof (theme as any).getThinkingBorderColor === "function"
+ ? (theme as any).getThinkingBorderColor(thinkLevel || "off")
+ : (s: string) => theme.fg("borderMuted", s);
+ const cpad = Math.max(0, inner - visibleWidth(content));
+ const railed = `${border("│")} ${content}${" ".repeat(cpad)} ${border("│")}`;
+ const bottom = border("╰" + "─".repeat(Math.max(0, width - 2)) + "╯");
+ return [railed, bottom];
+}
+
+export default function (pi: ExtensionAPI) {
+ let appliedCtx: any;
+ let tuiRef: any;
+
+ const apply = (ctx: any) => {
+ if (appliedCtx === ctx) {
+ tuiRef?.requestRender?.();
+ return;
+ }
+ appliedCtx = ctx;
+ ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
+ tuiRef = tui;
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
+ return {
+ dispose: unsub,
+ invalidate() {},
+ render: (width: number) => renderFooter(width, ctx, theme, footerData),
+ };
+ });
+ };
+
+ // Apply on session start (fresh/resumed/reloaded) and on the first input
+ // (covers a mid-session /reload where session_start doesn't re-fire). Model/
+ // thinking changes reuse the same ctx, so they just nudge a repaint (render
+ // reads live data). All are idempotent via the appliedCtx guard.
+ pi.on("session_start", (_e, ctx) => {
+ thinkLevel = currentThinking(ctx) ?? "off";
+ apply(ctx);
+ });
+ pi.on("input", (_e, ctx) => apply(ctx));
+ pi.on("model_select", (_e, ctx) => {
+ thinkLevel = currentThinking(ctx) ?? "off"; // model default effort may change
+ apply(ctx);
+ });
+ pi.on("thinking_level_select", (e, ctx) => {
+ thinkLevel = (e as any)?.level ?? thinkLevel; // authoritative live level
+ apply(ctx);
+ });
+
+ pi.on("session_shutdown", () => {
+ try {
+ appliedCtx?.ui.setFooter(undefined);
+ } catch {
+ /* ignore */
+ }
+ });
+}