@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.
- package/README.md +206 -0
- package/index.ts +6 -0
- package/package.json +52 -0
- package/src/commands.ts +204 -0
- package/src/config.ts +177 -0
- package/src/events.ts +256 -0
- package/src/index.ts +208 -0
- package/src/presets.ts +131 -0
- package/src/registry/index.ts +162 -0
- package/src/rendering/icons.ts +318 -0
- package/src/rendering/renderer.ts +310 -0
- package/src/rendering/separators.ts +112 -0
- package/src/rendering/theme.ts +98 -0
- package/src/segments/compactor.ts +135 -0
- package/src/segments/core.ts +283 -0
- package/src/segments/kanboard.ts +75 -0
- package/src/segments/mcp.ts +100 -0
- package/src/segments/memory.ts +140 -0
- package/src/segments/notify.ts +50 -0
- package/src/segments/ralph.ts +109 -0
- package/src/segments/status-ext.ts +119 -0
- package/src/segments/workflow.ts +100 -0
- package/src/tui/settings-tui.ts +252 -0
- package/src/types.ts +183 -0
|
@@ -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
|
+
];
|