diff options
| author | user <user@clank> | 2026-07-23 00:52:40 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 19:34:35 +0200 |
| commit | 802eade8b4cb04e0f730237ee773254f6cf5cea5 (patch) | |
| tree | 6c79acd5b4e87869dd40ea5bd2e81a21380575e6 | |
| parent | input-frame: wrap the input editor in a real rounded box (diff) | |
| download | sl0ppi-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.ts | 17 | ||||
| -rw-r--r-- | pi-ext/sl0p-footer.ts | 198 |
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 */ + } + }); +} |
