@pi-unipi/footer 0.1.1

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @pi-unipi/footer — Core segments
3
+ *
4
+ * Segment renderers for the core group: model, thinking, path, git,
5
+ * context_pct, cost, tokens_total, tokens_in, tokens_out, session,
6
+ * hostname, time.
7
+ */
8
+
9
+ import { hostname as osHostname } from "node:os";
10
+ import { basename } from "node:path";
11
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColor } from "../types.js";
12
+ import { applyColor } from "../rendering/theme.js";
13
+ import { getIcon } from "../rendering/icons.js";
14
+
15
+ // ─── Helpers ────────────────────────────────────────────────────────────────
16
+
17
+ function withIcon(segmentId: string, text: string): string {
18
+ const icon = getIcon(segmentId);
19
+ return icon ? `${icon} ${text}` : text;
20
+ }
21
+
22
+ function formatTokens(n: number): string {
23
+ if (n < 1000) return n.toString();
24
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
25
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
26
+ if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
27
+ return `${Math.round(n / 1000000)}M`;
28
+ }
29
+
30
+ function color(ctx: FooterSegmentContext, semantic: SemanticColor, text: string): string {
31
+ return applyColor(semantic, text, ctx.theme, ctx.colors);
32
+ }
33
+
34
+ /** Extract usage stats from piContext */
35
+ interface UsageStats {
36
+ input: number;
37
+ output: number;
38
+ cacheRead: number;
39
+ cacheWrite: number;
40
+ cost: number;
41
+ }
42
+
43
+ function getUsageStats(piContext: unknown): UsageStats {
44
+ const ctx = piContext as Record<string, unknown> | undefined;
45
+ if (!ctx) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
46
+
47
+ let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
48
+ const sessionEvents = (ctx.sessionManager as any)?.getBranch?.() ?? [];
49
+ for (const e of sessionEvents) {
50
+ if (!e || typeof e !== "object") continue;
51
+ if (e.type !== "message") continue;
52
+ const m = e.message;
53
+ if (!m || m.role !== "assistant") continue;
54
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
55
+ input += m.usage?.input ?? 0;
56
+ output += m.usage?.output ?? 0;
57
+ cacheRead += m.usage?.cacheRead ?? 0;
58
+ cacheWrite += m.usage?.cacheWrite ?? 0;
59
+ cost += m.usage?.cost?.total ?? 0;
60
+ }
61
+ return { input, output, cacheRead, cacheWrite, cost };
62
+ }
63
+
64
+ // ─── Rainbow helpers for xhigh thinking level ───────────────────────────────
65
+
66
+ /** ANSI 256-color rainbow palette for xhigh thinking level */
67
+ const RAINBOW_COLORS = [
68
+ "\x1b[38;5;196m", // red
69
+ "\x1b[38;5;202m", // orange
70
+ "\x1b[38;5;226m", // yellow
71
+ "\x1b[38;5;82m", // green
72
+ "\x1b[38;5;45m", // blue
73
+ "\x1b[38;5;129m", // indigo
74
+ "\x1b[38;5;171m", // violet
75
+ ];
76
+
77
+ const ANSI_RESET = "\x1b[0m";
78
+
79
+ /** Apply rainbow coloring to text, cycling through colors per character */
80
+ export function rainbowText(text: string): string {
81
+ let result = "";
82
+ let colorIdx = 0;
83
+ for (const char of text) {
84
+ if (char === " ") {
85
+ result += char;
86
+ } else {
87
+ result += `${RAINBOW_COLORS[colorIdx % RAINBOW_COLORS.length]}${char}${ANSI_RESET}`;
88
+ colorIdx++;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ /** Render a rainbow border line of the given width */
95
+ export function rainbowBorder(width: number): string {
96
+ let result = "";
97
+ for (let i = 0; i < width; i++) {
98
+ result += `${RAINBOW_COLORS[i % RAINBOW_COLORS.length]}─${ANSI_RESET}`;
99
+ }
100
+ return result;
101
+ }
102
+
103
+ /** Get the current thinking level from piContext */
104
+ export function getThinkingLevel(piContext: unknown): string {
105
+ const piCtx = piContext as Record<string, unknown> | undefined;
106
+ return typeof piCtx?.getThinkingLevel === "function"
107
+ ? (piCtx as any).getThinkingLevel()
108
+ : "off";
109
+ }
110
+
111
+ // ─── Segment Renderers ──────────────────────────────────────────────────────
112
+
113
+ function renderModelSegment(ctx: FooterSegmentContext): RenderedSegment {
114
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
115
+ const model = piCtx?.model as Record<string, unknown> | undefined;
116
+ let modelName = (model?.name || model?.id || "no-model") as string;
117
+ if (modelName.startsWith("Claude ")) {
118
+ modelName = modelName.slice(7);
119
+ }
120
+ const content = withIcon("model", modelName);
121
+ return { content: color(ctx, "model", content), visible: true };
122
+ }
123
+
124
+ function renderThinkingSegment(ctx: FooterSegmentContext): RenderedSegment {
125
+ const thinkingLevel = getThinkingLevel(ctx.piContext);
126
+
127
+ if (thinkingLevel === "off") return { content: "", visible: false };
128
+
129
+ const levelText: Record<string, string> = {
130
+ minimal: "min", low: "low", medium: "med", high: "high", xhigh: "xhigh",
131
+ };
132
+ const label = levelText[thinkingLevel] || thinkingLevel;
133
+ const icon = getIcon("thinking");
134
+ const text = `think:${label}`;
135
+ const content = icon ? `${icon} ${text}` : text;
136
+
137
+ // xhigh uses rainbow coloring
138
+ if (thinkingLevel === "xhigh") {
139
+ return { content: rainbowText(content), visible: true };
140
+ }
141
+
142
+ let semanticColor: SemanticColor = "thinking";
143
+ if (thinkingLevel === "minimal") semanticColor = "thinkingMinimal";
144
+ else if (thinkingLevel === "low") semanticColor = "thinkingLow";
145
+ else if (thinkingLevel === "medium") semanticColor = "thinkingMedium";
146
+ else if (thinkingLevel === "high") semanticColor = "thinkingHigh";
147
+
148
+ return { content: color(ctx, semanticColor, content), visible: true };
149
+ }
150
+
151
+ function renderPathSegment(ctx: FooterSegmentContext): RenderedSegment {
152
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
153
+ const cwd = (piCtx?.cwd as string) || process.cwd();
154
+ const home = process.env.HOME || process.env.USERPROFILE;
155
+ let pwd = cwd;
156
+
157
+ if (home && pwd.startsWith(home)) {
158
+ pwd = `~${pwd.slice(home.length)}`;
159
+ }
160
+ // For brevity, show basename by default
161
+ if (pwd.length > 30) {
162
+ pwd = `…${pwd.slice(-29)}`;
163
+ }
164
+
165
+ const content = withIcon("path", pwd);
166
+ return { content: color(ctx, "path", content), visible: true };
167
+ }
168
+
169
+ function renderGitSegment(ctx: FooterSegmentContext): RenderedSegment {
170
+ const footerData = ctx.footerData as any;
171
+ const branch = footerData?.getGitBranch?.() ?? null;
172
+ if (!branch) return { content: "", visible: false };
173
+
174
+ const content = withIcon("git", branch);
175
+ return { content: color(ctx, "git", content), visible: true };
176
+ }
177
+
178
+ function renderContextPctSegment(ctx: FooterSegmentContext): RenderedSegment {
179
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
180
+
181
+ // Use pi's built-in getContextUsage() — handles compaction and cache correctly
182
+ const contextUsage = typeof (piCtx as any)?.getContextUsage === "function"
183
+ ? (piCtx as any).getContextUsage()
184
+ : undefined;
185
+
186
+ const model = piCtx?.model as Record<string, unknown> | undefined;
187
+ const contextWindow = contextUsage?.contextWindow ?? (model?.contextWindow as number) ?? 0;
188
+ if (!contextWindow) return { content: "", visible: false };
189
+
190
+ const pct = contextUsage?.percent;
191
+ const tokens = contextUsage?.tokens;
192
+
193
+ // If percent is null (post-compaction, awaiting next response), show ?%
194
+ const pctDisplay = pct !== null && pct !== undefined ? pct.toFixed(1) : "?";
195
+ const text = `${pctDisplay}%/${formatTokens(contextWindow)}`;
196
+ const content = withIcon("context", text);
197
+
198
+ let semanticColor: SemanticColor = "context";
199
+ if (pct !== null && pct !== undefined) {
200
+ if (pct > 90) semanticColor = "contextError";
201
+ else if (pct > 70) semanticColor = "contextWarn";
202
+ }
203
+
204
+ return { content: color(ctx, semanticColor, content), visible: true };
205
+ }
206
+
207
+ function renderCostSegment(ctx: FooterSegmentContext): RenderedSegment {
208
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
209
+ const stats = getUsageStats(piCtx);
210
+ const usingSubscription = piCtx?.model
211
+ ? (piCtx as any).modelRegistry?.isUsingOAuth?.(piCtx.model) ?? false
212
+ : false;
213
+
214
+ if (!stats.cost && !usingSubscription) return { content: "", visible: false };
215
+
216
+ const costDisplay = usingSubscription ? "(sub)" : `$${stats.cost.toFixed(2)}`;
217
+ return { content: color(ctx, "cost", costDisplay), visible: true };
218
+ }
219
+
220
+ function renderTokensSegment(variant: "total" | "in" | "out"): (ctx: FooterSegmentContext) => RenderedSegment {
221
+ return (ctx: FooterSegmentContext) => {
222
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
223
+ const stats = getUsageStats(piCtx);
224
+
225
+ let value: number;
226
+ let segmentId: string;
227
+ if (variant === "in") {
228
+ value = stats.input;
229
+ segmentId = "tokensIn";
230
+ } else if (variant === "out") {
231
+ value = stats.output;
232
+ segmentId = "tokensOut";
233
+ } else {
234
+ value = stats.input + stats.output + stats.cacheRead + stats.cacheWrite;
235
+ segmentId = "tokens";
236
+ }
237
+
238
+ if (!value) return { content: "", visible: false };
239
+
240
+ const content = withIcon(segmentId, formatTokens(value));
241
+ return { content: color(ctx, "tokens", content), visible: true };
242
+ };
243
+ }
244
+
245
+ function renderSessionSegment(ctx: FooterSegmentContext): RenderedSegment {
246
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
247
+ const sessionId = (piCtx?.sessionManager as any)?.getSessionId?.();
248
+ const display = sessionId?.slice(0, 8) || "new";
249
+ const content = withIcon("session", display);
250
+ return { content: color(ctx, "model", content), visible: true };
251
+ }
252
+
253
+ function renderHostnameSegment(_ctx: FooterSegmentContext): RenderedSegment {
254
+ const name = osHostname().split(".")[0];
255
+ const content = withIcon("hostname", name);
256
+ return { content, visible: true };
257
+ }
258
+
259
+ function renderTimeSegment(ctx: FooterSegmentContext): RenderedSegment {
260
+ const now = new Date();
261
+ const hours = now.getHours();
262
+ const mins = now.getMinutes().toString().padStart(2, "0");
263
+ const timeStr = `${hours}:${mins}`;
264
+ const content = withIcon("time", timeStr);
265
+ return { content, visible: true };
266
+ }
267
+
268
+ // ─── Core segments array ────────────────────────────────────────────────────
269
+
270
+ export const CORE_SEGMENTS: FooterSegment[] = [
271
+ { id: "model", label: "Model", icon: "", render: renderModelSegment, defaultShow: true },
272
+ { id: "thinking", label: "Thinking", icon: "", render: renderThinkingSegment, defaultShow: true },
273
+ { id: "path", label: "Path", icon: "", render: renderPathSegment, defaultShow: true },
274
+ { id: "git", label: "Git", icon: "", render: renderGitSegment, defaultShow: true },
275
+ { id: "context_pct", label: "Context %", icon: "", render: renderContextPctSegment, defaultShow: true },
276
+ { id: "cost", label: "Cost", icon: "", render: renderCostSegment, defaultShow: true },
277
+ { id: "tokens_total", label: "Tokens Total", icon: "", render: renderTokensSegment("total"), defaultShow: false },
278
+ { id: "tokens_in", label: "Tokens In", icon: "", render: renderTokensSegment("in"), defaultShow: false },
279
+ { id: "tokens_out", label: "Tokens Out", icon: "", render: renderTokensSegment("out"), defaultShow: false },
280
+ { id: "session", label: "Session", icon: "", render: renderSessionSegment, defaultShow: false },
281
+ { id: "hostname", label: "Hostname", icon: "", render: renderHostnameSegment, defaultShow: false },
282
+ { id: "time", label: "Time", icon: "", render: renderTimeSegment, defaultShow: false },
283
+ ];
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @pi-unipi/footer — Kanboard segments
3
+ *
4
+ * Segment renderers for the kanboard group: docs_count, tasks_done,
5
+ * tasks_total, task_pct.
6
+ * Reads directly from kanboard's parser registry (no events).
7
+ */
8
+
9
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
10
+ import { applyColor } from "../rendering/theme.js";
11
+ import { getIcon } from "../rendering/icons.js";
12
+
13
+ function withIcon(segmentId: string, text: string): string {
14
+ const icon = getIcon(segmentId);
15
+ return icon ? `${icon} ${text}` : text;
16
+ }
17
+
18
+ /**
19
+ * Try to read kanboard data from globalThis registry.
20
+ * Kanboard doesn't emit events — it exposes its parser via globalThis.
21
+ */
22
+ function getKanboardData(): Record<string, unknown> | null {
23
+ try {
24
+ const registry = (globalThis as Record<string, unknown>).__unipi_kanboard_registry;
25
+ if (!registry || typeof registry !== "object") return null;
26
+ return registry as Record<string, unknown>;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function renderDocsCountSegment(ctx: FooterSegmentContext): RenderedSegment {
33
+ const kb = getKanboardData();
34
+ const value = kb?.docsCount;
35
+ if (value === undefined || value === null) return { content: "", visible: false };
36
+ const content = withIcon("docsCount", `${value}`);
37
+ return { content: applyColor("kanboard", content, ctx.theme, ctx.colors), visible: true };
38
+ }
39
+
40
+ function renderTasksDoneSegment(ctx: FooterSegmentContext): RenderedSegment {
41
+ const kb = getKanboardData();
42
+ const value = kb?.tasksDone;
43
+ if (value === undefined || value === null) return { content: "", visible: false };
44
+ const content = withIcon("tasksDone", `${value}`);
45
+ return { content: applyColor("kanboard", content, ctx.theme, ctx.colors), visible: true };
46
+ }
47
+
48
+ function renderTasksTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
49
+ const kb = getKanboardData();
50
+ const value = kb?.tasksTotal;
51
+ if (value === undefined || value === null) return { content: "", visible: false };
52
+ const content = withIcon("tasksTotal", `${value}`);
53
+ return { content: applyColor("kanboard", content, ctx.theme, ctx.colors), visible: true };
54
+ }
55
+
56
+ function renderTaskPctSegment(ctx: FooterSegmentContext): RenderedSegment {
57
+ const kb = getKanboardData();
58
+ const done = kb?.tasksDone as number | undefined;
59
+ const total = kb?.tasksTotal as number | undefined;
60
+
61
+ if (done === undefined || total === undefined || total === 0) {
62
+ return { content: "", visible: false };
63
+ }
64
+
65
+ const pct = Math.round((done / total) * 100);
66
+ const content = withIcon("taskPct", `${pct}%`);
67
+ return { content: applyColor("kanboard", content, ctx.theme, ctx.colors), visible: true };
68
+ }
69
+
70
+ export const KANBOARD_SEGMENTS: FooterSegment[] = [
71
+ { id: "docs_count", label: "Docs Count", icon: "", render: renderDocsCountSegment, defaultShow: true },
72
+ { id: "tasks_done", label: "Tasks Done", icon: "", render: renderTasksDoneSegment, defaultShow: true },
73
+ { id: "tasks_total", label: "Tasks Total", icon: "", render: renderTasksTotalSegment, defaultShow: true },
74
+ { id: "task_pct", label: "Task %", icon: "", render: renderTaskPctSegment, defaultShow: true },
75
+ ];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @pi-unipi/footer — MCP segments
3
+ *
4
+ * Segment renderers for the MCP group: servers_total, servers_active,
5
+ * tools_total, servers_failed.
6
+ *
7
+ * Data sourced from:
8
+ * 1. globalThis.__unipi_mcp_stats (if available — direct from MCP registry)
9
+ * 2. ctx.data aggregate fields maintained by events.ts handlers
10
+ * 3. Falls back to hidden when no meaningful data is available
11
+ *
12
+ * Never shows "—" — segments hide instead of showing placeholder values.
13
+ */
14
+
15
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
16
+ import { applyColor } from "../rendering/theme.js";
17
+ import { getIcon } from "../rendering/icons.js";
18
+
19
+ /** Shape of aggregate MCP stats from globalThis or registry */
20
+ interface McpStats {
21
+ serversTotal?: number;
22
+ serversActive?: number;
23
+ serversFailed?: number;
24
+ toolsTotal?: number;
25
+ }
26
+
27
+ /** Shape of the global escape-hatch object */
28
+ interface GlobalMcpStats extends McpStats {}
29
+
30
+ declare global {
31
+ // eslint-disable-next-line no-var
32
+ var __unipi_mcp_stats: GlobalMcpStats | undefined;
33
+ }
34
+
35
+ function withIcon(segmentId: string, text: string): string {
36
+ const icon = getIcon(segmentId);
37
+ return icon ? `${icon} ${text}` : text;
38
+ }
39
+
40
+ /**
41
+ * Resolve MCP stats from available sources:
42
+ * 1. globalThis.__unipi_mcp_stats (direct from MCP registry)
43
+ * 2. ctx.data aggregate fields (maintained by events.ts)
44
+ */
45
+ function getMcpStats(ctx: FooterSegmentContext): McpStats {
46
+ // Source 1: globalThis escape hatch (future: MCP registry may expose this)
47
+ const global = globalThis.__unipi_mcp_stats;
48
+ if (global && typeof global === "object") {
49
+ return global;
50
+ }
51
+
52
+ // Source 2: registry data with aggregate fields
53
+ const data = ctx.data;
54
+ if (data && typeof data === "object") {
55
+ return data as McpStats;
56
+ }
57
+
58
+ return {};
59
+ }
60
+
61
+ function hasUsefulValue(value: unknown): value is number {
62
+ return typeof value === "number";
63
+ }
64
+
65
+ function renderServersTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
66
+ const stats = getMcpStats(ctx);
67
+ if (!hasUsefulValue(stats.serversTotal)) return { content: "", visible: false };
68
+ const content = withIcon("serversTotal", `${stats.serversTotal}`);
69
+ return { content: applyColor("mcp", content, ctx.theme, ctx.colors), visible: true };
70
+ }
71
+
72
+ function renderServersActiveSegment(ctx: FooterSegmentContext): RenderedSegment {
73
+ const stats = getMcpStats(ctx);
74
+ if (!hasUsefulValue(stats.serversActive)) return { content: "", visible: false };
75
+ const content = withIcon("serversActive", `${stats.serversActive}`);
76
+ return { content: applyColor("mcp", content, ctx.theme, ctx.colors), visible: true };
77
+ }
78
+
79
+ function renderToolsTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
80
+ const stats = getMcpStats(ctx);
81
+ if (!hasUsefulValue(stats.toolsTotal)) return { content: "", visible: false };
82
+ const content = withIcon("toolsTotal", `${stats.toolsTotal}`);
83
+ return { content: applyColor("mcp", content, ctx.theme, ctx.colors), visible: true };
84
+ }
85
+
86
+ function renderServersFailedSegment(ctx: FooterSegmentContext): RenderedSegment {
87
+ const stats = getMcpStats(ctx);
88
+ if (!hasUsefulValue(stats.serversFailed) || stats.serversFailed === 0) {
89
+ return { content: "", visible: false };
90
+ }
91
+ const content = withIcon("serversFailed", `${stats.serversFailed}`);
92
+ return { content: applyColor("mcp", content, ctx.theme, ctx.colors), visible: true };
93
+ }
94
+
95
+ export const MCP_SEGMENTS: FooterSegment[] = [
96
+ { id: "servers_total", label: "Servers Total", icon: "", render: renderServersTotalSegment, defaultShow: true },
97
+ { id: "servers_active", label: "Servers Active", icon: "", render: renderServersActiveSegment, defaultShow: true },
98
+ { id: "tools_total", label: "Tools Total", icon: "", render: renderToolsTotalSegment, defaultShow: true },
99
+ { id: "servers_failed", label: "Servers Failed", icon: "", render: renderServersFailedSegment, defaultShow: true },
100
+ ];
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @pi-unipi/footer — Memory segments
3
+ *
4
+ * Segment renderers for the memory group: project_count, total_count, consolidations.
5
+ *
6
+ * Data sources (in priority order):
7
+ * 1. `globalThis.__unipi_info_registry` — the info-screen registry exposes a
8
+ * memory group with a dataProvider that returns { projectCount: { value }, totalCount: { value } }.
9
+ * We read from its cache synchronously via getCachedData("memory").
10
+ * 2. `ctx.data` — the footer registry cache, populated by MEMORY_STORED/DELETED/CONSOLIDATED
11
+ * events. These carry raw event payloads (not aggregate counts), so they are NOT
12
+ * used for count segments. They may still carry lastConsolidated info.
13
+ *
14
+ * If no data source has aggregate counts available, segments return visible: false
15
+ * instead of showing a stale "—" placeholder.
16
+ *
17
+ * Display format:  76/102 (project/total) — uses  Nerd Font icon
18
+ */
19
+
20
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
21
+ import { applyColor } from "../rendering/theme.js";
22
+ import { getIcon } from "../rendering/icons.js";
23
+
24
+ /** Nerd Font icon for memory: */
25
+ const MEMORY_ICON = "\uee9c";
26
+
27
+ /**
28
+ * Shape of the info-screen memory group data:
29
+ * { projectCount: { value: string }, totalCount: { value: string }, ... }
30
+ */
31
+ interface InfoMemoryData {
32
+ projectCount?: { value: string };
33
+ totalCount?: { value: string };
34
+ consolidations?: { value: string };
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /**
39
+ * Shape of the info-screen registry (subset we use).
40
+ */
41
+ interface InfoRegistryLike {
42
+ getCachedData(groupId: string): Record<string, unknown> | null;
43
+ }
44
+
45
+ /**
46
+ * Try to read memory stats from the info-screen registry on globalThis.
47
+ * Returns the cached data for the "memory" group if available.
48
+ */
49
+ function getInfoRegistryMemoryData(): InfoMemoryData | null {
50
+ try {
51
+ const g = globalThis as Record<string, unknown>;
52
+ const registry = g.__unipi_info_registry;
53
+ if (!registry || typeof registry !== "object") return null;
54
+
55
+ // The info registry exposes getCachedData(groupId) synchronously
56
+ const getCached = (registry as InfoRegistryLike).getCachedData;
57
+ if (typeof getCached !== "function") return null;
58
+
59
+ const data = getCached.call(registry, "memory");
60
+ if (!data || typeof data !== "object") return null;
61
+
62
+ return data as InfoMemoryData;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** Get project and total counts from info registry */
69
+ function getMemoryCounts(): { project: number | null; total: number | null } {
70
+ const infoData = getInfoRegistryMemoryData();
71
+ const projectStr = infoData?.projectCount?.value;
72
+ const totalStr = infoData?.totalCount?.value;
73
+ return {
74
+ project: projectStr !== undefined ? parseInt(projectStr, 10) : null,
75
+ total: totalStr !== undefined ? parseInt(totalStr, 10) : null,
76
+ };
77
+ }
78
+
79
+ function renderProjectCountSegment(ctx: FooterSegmentContext): RenderedSegment {
80
+ const counts = getMemoryCounts();
81
+ if (counts.project === null) {
82
+ return { content: "", visible: false };
83
+ }
84
+
85
+ // Show as 76/102 format when both are available
86
+ if (counts.total !== null) {
87
+ const content = `${MEMORY_ICON} ${counts.project}/${counts.total}`;
88
+ return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
89
+ }
90
+
91
+ const content = `${MEMORY_ICON} ${counts.project}`;
92
+ return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
93
+ }
94
+
95
+ function renderTotalCountSegment(ctx: FooterSegmentContext): RenderedSegment {
96
+ // If project_count already shows project/total, this segment is redundant
97
+ // Only show when project_count isn't showing the combined format
98
+ const counts = getMemoryCounts();
99
+ if (counts.total === null) {
100
+ return { content: "", visible: false };
101
+ }
102
+
103
+ // If both project and total are available, project_count shows the combined view
104
+ // Only show this as standalone when project count isn't available
105
+ if (counts.project !== null) {
106
+ return { content: "", visible: false };
107
+ }
108
+
109
+ const content = `${MEMORY_ICON} ${counts.total}`;
110
+ return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
111
+ }
112
+
113
+ function renderConsolidationsSegment(ctx: FooterSegmentContext): RenderedSegment {
114
+ const infoData = getInfoRegistryMemoryData();
115
+
116
+ // Check for explicit consolidations stat from info registry
117
+ const consolidationsValue = infoData?.consolidations?.value;
118
+ if (consolidationsValue !== undefined && consolidationsValue !== null) {
119
+ const content = `${MEMORY_ICON} cns:${consolidationsValue}`;
120
+ return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
121
+ }
122
+
123
+ // Fall back to lastConsolidated from footer event cache (ctx.data)
124
+ const eventData = ctx.data as Record<string, unknown> | undefined;
125
+ const lastConsolidated = eventData?.lastConsolidated as Record<string, unknown> | undefined;
126
+ const count = lastConsolidated?.count;
127
+
128
+ if (count === undefined || count === null) {
129
+ return { content: "", visible: false };
130
+ }
131
+
132
+ const content = `${MEMORY_ICON} cns:${count}`;
133
+ return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
134
+ }
135
+
136
+ export const MEMORY_SEGMENTS: FooterSegment[] = [
137
+ { id: "project_count", label: "Project Count", icon: "", render: renderProjectCountSegment, defaultShow: true },
138
+ { id: "total_count", label: "Total Count", icon: "", render: renderTotalCountSegment, defaultShow: true },
139
+ { id: "consolidations", label: "Consolidations", icon: "", render: renderConsolidationsSegment, defaultShow: false },
140
+ ];
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @pi-unipi/footer — Notify segments
3
+ *
4
+ * Segment renderers for the notify group: platforms_enabled, last_sent.
5
+ * Data sourced from NOTIFICATION_SENT event via registry cache.
6
+ */
7
+
8
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
9
+ import { applyColor } from "../rendering/theme.js";
10
+ import { getIcon } from "../rendering/icons.js";
11
+
12
+ function withIcon(segmentId: string, text: string): string {
13
+ const icon = getIcon(segmentId);
14
+ return icon ? `${icon} ${text}` : text;
15
+ }
16
+
17
+ function getNotifyData(ctx: FooterSegmentContext): Record<string, unknown> {
18
+ const data = ctx.data;
19
+ if (!data || typeof data !== "object") return {};
20
+ return data as Record<string, unknown>;
21
+ }
22
+
23
+ function renderPlatformsEnabledSegment(ctx: FooterSegmentContext): RenderedSegment {
24
+ const data = getNotifyData(ctx);
25
+ const platforms = data.platforms as string[] | undefined;
26
+ if (!platforms || platforms.length === 0) return { content: "", visible: false };
27
+
28
+ const content = withIcon("platformsEnabled", platforms.join(","));
29
+ return { content: applyColor("notify", content, ctx.theme, ctx.colors), visible: true };
30
+ }
31
+
32
+ function renderLastSentSegment(ctx: FooterSegmentContext): RenderedSegment {
33
+ const data = getNotifyData(ctx);
34
+ const timestamp = data.timestamp as string | undefined;
35
+ if (!timestamp) return { content: "", visible: false };
36
+
37
+ // Show relative time
38
+ const sent = new Date(timestamp);
39
+ const diffMs = Date.now() - sent.getTime();
40
+ const diffMin = Math.floor(diffMs / 60000);
41
+ const display = diffMin < 1 ? "just now" : diffMin < 60 ? `${diffMin}m ago` : `${Math.floor(diffMin / 60)}h ago`;
42
+
43
+ const content = withIcon("lastSent", display);
44
+ return { content: applyColor("notify", content, ctx.theme, ctx.colors), visible: true };
45
+ }
46
+
47
+ export const NOTIFY_SEGMENTS: FooterSegment[] = [
48
+ { id: "platforms_enabled", label: "Platforms", icon: "", render: renderPlatformsEnabledSegment, defaultShow: true },
49
+ { id: "last_sent", label: "Last Sent", icon: "", render: renderLastSentSegment, defaultShow: true },
50
+ ];