aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/sl0p-footer.ts
blob: 070d5a7ae6d2f4b007b3d6a8343ad6de29024667 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
 * 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 plus 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
 *   ╰─────────────────────────────────────╯
 *
 * No side rails: input-frame.ts dropped them (the zellij pane borders itself),
 * and this line has to match it or the seam looks broken.
 *
 * 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, metrics left / model right;
  // truncate the left (path first) if tight.
  const inner = Math.max(1, width - 4); // 2-col indent + 2-col right margin
  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);
  }

  // Indent to the box interior + close with the bottom border. Color the border
  // with the SAME thinking-level 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);
  // Straight end caps to match the rest of the chrome (no side rails → nothing
  // for a corner to turn into). Use ╰ / ╯ here to restore the rounded box.
  const bottom = border("─".repeat(Math.max(0, width)));
  return [`  ${content}`, 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 */
    }
  });
}