@mrclrchtr/supi-extras 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,6 +44,7 @@
44
44
  "./config": "./src/config.ts",
45
45
  "./context": "./src/context.ts",
46
46
  "./debug": "./src/debug-registry.ts",
47
+ "./footer-registry": "./src/footer-registry.ts",
47
48
  "./llm": "./src/llm.ts",
48
49
  "./package.json": "./package.json",
49
50
  "./path": "./src/path.ts",
@@ -13,6 +13,8 @@ export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
14
  export * from "./debug-registry.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./footer-registry.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./llm.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./path.ts";
@@ -0,0 +1,57 @@
1
+ // Shared footer contribution registry for SuPi extensions.
2
+ //
3
+ // Extensions register pre-styled text chunks with a placement hint
4
+ // ("stats" for the metrics line, "status" for the extension status line).
5
+ // The custom footer in supi-extras (or PI's built-in footer) reads these
6
+ // contributions and renders them alongside the built-in metrics.
7
+
8
+ import { createRegistry } from "./registry-utils.ts";
9
+
10
+ /** Where the contribution should appear in the footer. */
11
+ export type FooterPlacement = "stats" | "status";
12
+
13
+ /** A single footer contribution registered by an extension. */
14
+ export interface FooterContribution {
15
+ /** Unique key for this contribution. Re-registering with the same key replaces it. */
16
+ key: string;
17
+ /** Which footer line this belongs on. */
18
+ placement: FooterPlacement;
19
+ /**
20
+ * Sort order within the placement (lower values render further left). Default: 100.
21
+ * Priority 0 is reserved for the turn cache-hit part so it stays adjacent to CH.
22
+ */
23
+ priority?: number;
24
+ /** Return the pre-styled text for this contribution. Called on every render. */
25
+ render: () => string;
26
+ }
27
+
28
+ const registry = createRegistry<FooterContribution>("footer-contributions");
29
+
30
+ function sortByPriority(a: FooterContribution, b: FooterContribution): number {
31
+ return (a.priority ?? 100) - (b.priority ?? 100);
32
+ }
33
+
34
+ export const footerContributions = {
35
+ /** Register or replace a footer contribution. */
36
+ register(contribution: FooterContribution): void {
37
+ registry.register(contribution.key, contribution);
38
+ },
39
+
40
+ /** Remove a contribution (e.g. on session_shutdown or when disabled). */
41
+ unregister(key: string): void {
42
+ registry.unregister(key);
43
+ },
44
+
45
+ /** Get contributions for a specific placement, sorted by priority. */
46
+ getByPlacement(placement: FooterPlacement): FooterContribution[] {
47
+ return registry
48
+ .getAll()
49
+ .filter((c) => c.placement === placement)
50
+ .sort(sortByPriority);
51
+ },
52
+
53
+ /** Remove all contributions (primarily for tests). */
54
+ clear(): void {
55
+ registry.clear();
56
+ },
57
+ };
@@ -13,6 +13,8 @@ export * from "./context.ts";
13
13
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
14
14
  export * from "./debug-registry.ts";
15
15
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
+ export * from "./footer-registry.ts";
17
+ // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
16
18
  export * from "./path.ts";
17
19
  // biome-ignore lint/performance/noReExportAll: intentional convenience barrel
18
20
  export * from "./project.ts";
@@ -27,7 +27,7 @@ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
27
27
  *
28
28
  * @typeParam T - The value type stored in the registry.
29
29
  * @param name - Unique registry name (used to construct the `Symbol.for` key).
30
- * @returns An object with `register`, `getAll`, and `clear` functions.
30
+ * @returns An object with `register`, `unregister`, `getAll`, and `clear` functions.
31
31
  */
32
32
  export function createRegistry<T>(name: string) {
33
33
  const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
@@ -40,6 +40,13 @@ export function createRegistry<T>(name: string) {
40
40
  getMap().set(id, value);
41
41
  },
42
42
 
43
+ /**
44
+ * Remove a registration by id. No-op if not registered.
45
+ */
46
+ unregister: (id: string): void => {
47
+ getMap().delete(id);
48
+ },
49
+
43
50
  /**
44
51
  * Get all registered values in registration order.
45
52
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-extras",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "SuPi extras — command aliases, skill shorthand, tab spinner, /supi-stash prompt stash with TUI overlay, and other small utilities",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "clipboardy": "^5.3.1",
24
- "@mrclrchtr/supi-core": "1.12.1"
24
+ "@mrclrchtr/supi-core": "1.13.0"
25
25
  },
26
26
  "bundledDependencies": [
27
27
  "@mrclrchtr/supi-core"
@@ -129,35 +129,74 @@ export function gatherUsage(entries: ReadonlyArray<UsageEntry>): UsageTotals {
129
129
 
130
130
  // ---- stats builder ----
131
131
 
132
- /** Build the left-side stats string. */
133
- export function buildStatsLeft(params: {
134
- contextWindow: number;
135
- percent: number | null;
136
- usage: UsageTotals;
137
- useSubscription: boolean;
138
- }): { text: string; contextPercentValue: number } {
139
- const { contextWindow, percent, usage, useSubscription } = params;
140
- const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = usage;
132
+ /** Push token-metric parts (↑ ↓ R W) onto the array. */
133
+ function pushTokenParts(parts: string[], usage: UsageTotals): void {
134
+ if (usage.totalInput) parts.push(`↑${formatTokens(usage.totalInput)}`);
135
+ if (usage.totalOutput) parts.push(`↓${formatTokens(usage.totalOutput)}`);
136
+ if (usage.totalCacheRead) parts.push(`R${formatTokens(usage.totalCacheRead)}`);
137
+ if (usage.totalCacheWrite) parts.push(`W${formatTokens(usage.totalCacheWrite)}`);
138
+ }
139
+
140
+ /**
141
+ * Push CH (cumulative session cache hit rate) onto the array.
142
+ * Denominator includes cacheRead + cacheWrite + input (matches PI's built-in footer).
143
+ * Contrast with TCH which is per-turn and excludes cacheWrite.
144
+ */
145
+ function pushCHPart(parts: string[], usage: UsageTotals): void {
146
+ const { totalCacheRead, totalCacheWrite, totalInput } = usage;
147
+ const denom = totalCacheRead + totalCacheWrite + totalInput;
148
+ if ((totalCacheRead > 0 || totalCacheWrite > 0) && denom > 0) {
149
+ parts.push(`CH${((totalCacheRead / denom) * 100).toFixed(1)}%`);
150
+ }
151
+ }
141
152
 
142
- const contextPercentValue = percent ?? 0;
153
+ /** Build the core stats parts array (↑↓RW CH extra cost context). */
154
+ function buildStatsParts(
155
+ usage: UsageTotals,
156
+ params: {
157
+ contextWindow: number;
158
+ percent: number | null;
159
+ useSubscription: boolean;
160
+ extraParts?: string[];
161
+ },
162
+ ): string[] {
163
+ const { totalCost } = usage;
164
+ const { contextWindow, percent, useSubscription, extraParts } = params;
143
165
  const contextPercent = percent != null ? percent.toFixed(1) : "?";
144
166
 
145
167
  const parts: string[] = [];
146
- if (totalInput) parts.push(`↑${formatTokens(totalInput)}`);
147
- if (totalOutput) parts.push(`↓${formatTokens(totalOutput)}`);
148
- if (totalCacheRead) parts.push(`R${formatTokens(totalCacheRead)}`);
149
- if (totalCacheWrite) parts.push(`W${formatTokens(totalCacheWrite)}`);
168
+ pushTokenParts(parts, usage);
169
+ pushCHPart(parts, usage);
170
+
171
+ for (const part of extraParts ?? []) {
172
+ if (part) parts.push(part);
173
+ }
150
174
 
151
175
  if (totalCost || useSubscription) {
152
176
  parts.push(`$${totalCost.toFixed(3)}${useSubscription ? " (sub)" : ""}`);
153
177
  }
154
178
 
155
- const ctxDisplay =
179
+ parts.push(
156
180
  contextPercent === "?"
157
181
  ? `?/${formatTokens(contextWindow)}`
158
- : `${contextPercent}%/${formatTokens(contextWindow)}`;
159
- parts.push(ctxDisplay);
182
+ : `${contextPercent}%/${formatTokens(contextWindow)}`,
183
+ );
160
184
 
185
+ return parts;
186
+ }
187
+
188
+ /** Build the left-side stats string. */
189
+ export function buildStatsLeft(params: {
190
+ contextWindow: number;
191
+ percent: number | null;
192
+ usage: UsageTotals;
193
+ useSubscription: boolean;
194
+ /** Extra parts inserted after CH, before cost and context. */
195
+ extraParts?: string[];
196
+ }): { text: string; contextPercentValue: number } {
197
+ const { usage } = params;
198
+ const contextPercentValue = params.percent ?? 0;
199
+ const parts = buildStatsParts(usage, params);
161
200
  return { text: parts.join(" "), contextPercentValue };
162
201
  }
163
202
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import { footerContributions } from "@mrclrchtr/supi-core/footer-registry";
11
12
  import {
12
13
  buildPwdLine,
13
14
  buildStatsLeft,
@@ -91,11 +92,17 @@ export default function modelEffortColors(pi: ExtensionAPI) {
91
92
  : false;
92
93
 
93
94
  // Stats
95
+ const statContribs = footerContributions
96
+ .getByPlacement("stats")
97
+ .map((c) => c.render())
98
+ .filter(Boolean);
99
+
94
100
  const rawStats = buildStatsLeft({
95
101
  contextWindow,
96
102
  percent: contextUsage?.percent ?? null,
97
103
  usage,
98
104
  useSubscription,
105
+ extraParts: statContribs,
99
106
  });
100
107
 
101
108
  let statsLeft = rawStats.text;
@@ -167,12 +174,21 @@ function buildStatusLine(
167
174
  theme: FooterTheme,
168
175
  ): void {
169
176
  const statuses = footerData.getExtensionStatuses();
170
- if (statuses.size === 0) return;
171
-
172
- const entries = Array.from(statuses.entries()) as Array<[string, string]>;
173
- const statusLine = entries
174
- .sort((a, b) => a[0].localeCompare(b[0]))
175
- .map(([, text]) => sanitizeStatusText(text))
176
- .join(" ");
177
+ const legacyEntries =
178
+ statuses.size > 0
179
+ ? (Array.from(statuses.entries()) as Array<[string, string]>)
180
+ .sort((a, b) => a[0].localeCompare(b[0]))
181
+ .map(([, text]) => sanitizeStatusText(text))
182
+ : [];
183
+
184
+ const contribEntries = footerContributions
185
+ .getByPlacement("status")
186
+ .map((c) => c.render())
187
+ .filter(Boolean);
188
+
189
+ const allEntries = [...legacyEntries, ...contribEntries];
190
+ if (allEntries.length === 0) return;
191
+
192
+ const statusLine = allEntries.join(" ");
177
193
  lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
178
194
  }