@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,310 @@
1
+ /**
2
+ * @pi-unipi/footer — FooterRenderer
3
+ *
4
+ * Main renderer using pi's setFooter + setWidget APIs.
5
+ * Implements responsive layout with top row + secondary row.
6
+ * Segments fit into available width; overflow goes to secondary.
7
+ */
8
+
9
+ import type { Theme } from "@mariozechner/pi-coding-agent";
10
+ import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, RenderedSegment } from "../types.js";
11
+ import type { FooterRegistry } from "../registry/index.js";
12
+ import { getSeparator, separatorVisibleWidth } from "./separators.js";
13
+ import { getDefaultColors } from "./theme.js";
14
+ import { setIconStyle } from "./icons.js";
15
+ import { getPreset } from "../presets.js";
16
+ import { isSegmentEnabled, loadFooterSettings } from "../config.js";
17
+
18
+ /** Segment lookup by ID across all groups */
19
+ interface SegmentLookup {
20
+ get(id: string): FooterSegment | undefined;
21
+ }
22
+
23
+ /** Rendered segment with width info */
24
+ interface RenderedSegmentWithWidth {
25
+ content: string;
26
+ width: number;
27
+ visible: boolean;
28
+ }
29
+
30
+ // ─── ANSI helpers ───────────────────────────────────────────────────────────
31
+
32
+ /** Strip ANSI escape codes and measure visible width */
33
+ function visibleWidth(text: string): number {
34
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
35
+ return stripped.length;
36
+ }
37
+
38
+ const ANSI_RESET = "\x1b[0m";
39
+
40
+ function getFgAnsiCode(colors: ColorScheme, semantic: string): string {
41
+ // Simplified: use dim color for separators
42
+ return "\x1b[2m"; // dim
43
+ }
44
+
45
+ // ─── FooterRenderer class ──────────────────────────────────────────────────
46
+
47
+ export class FooterRenderer {
48
+ /** Current active preset name */
49
+ private presetName: string;
50
+
51
+ /** Footer registry for data access */
52
+ private registry: FooterRegistry;
53
+
54
+ /** Segment lookup function */
55
+ private segmentLookup: SegmentLookup;
56
+
57
+ /** Current terminal width */
58
+ private currentWidth = 0;
59
+
60
+ /** Whether the renderer is active */
61
+ private active = false;
62
+
63
+ /** Layout cache */
64
+ private lastLayoutResult: { topContent: string; secondaryContent: string } | null = null;
65
+ private lastLayoutWidth = 0;
66
+ private lastLayoutTimestamp = 0;
67
+ private layoutDirty = true;
68
+
69
+ /** Pi context references */
70
+ private piContext: unknown = null;
71
+ private footerData: unknown = null;
72
+
73
+ /** Debounce timer for renders */
74
+ private renderTimer: ReturnType<typeof setTimeout> | null = null;
75
+ private static readonly RENDER_DEBOUNCE_MS = 33;
76
+
77
+ constructor(
78
+ registry: FooterRegistry,
79
+ segmentLookup: SegmentLookup,
80
+ initialPreset = "default",
81
+ ) {
82
+ this.registry = registry;
83
+ this.segmentLookup = segmentLookup;
84
+ this.presetName = initialPreset;
85
+ this.syncIconStyle();
86
+
87
+ // Subscribe to registry changes
88
+ this.registry.subscribe(() => {
89
+ this.layoutDirty = true;
90
+ this.scheduleRender();
91
+ });
92
+ }
93
+
94
+ /** Set the active preset */
95
+ setPreset(name: string): void {
96
+ this.presetName = name;
97
+ this.resetLayoutCache();
98
+ this.syncIconStyle();
99
+ }
100
+
101
+ /** Sync the icon style from settings to the icons module */
102
+ private syncIconStyle(): void {
103
+ const settings = loadFooterSettings();
104
+ setIconStyle(settings.iconStyle);
105
+ }
106
+
107
+ /** Get the active preset name */
108
+ getPresetName(): string {
109
+ return this.presetName;
110
+ }
111
+
112
+ /** Set pi context references */
113
+ setContext(piContext: unknown, footerData: unknown): void {
114
+ this.piContext = piContext;
115
+ this.footerData = footerData;
116
+ this.resetLayoutCache();
117
+ }
118
+
119
+ /** Activate/deactivate the renderer */
120
+ setActive(active: boolean): void {
121
+ this.active = active;
122
+ this.resetLayoutCache();
123
+ }
124
+
125
+ /** Whether the renderer is active */
126
+ isActive(): boolean {
127
+ return this.active;
128
+ }
129
+
130
+ /** Schedule a debounced render */
131
+ scheduleRender(): void {
132
+ if (this.renderTimer) clearTimeout(this.renderTimer);
133
+ this.renderTimer = setTimeout(() => {
134
+ this.layoutDirty = true;
135
+ }, FooterRenderer.RENDER_DEBOUNCE_MS);
136
+ }
137
+
138
+ /** Reset layout cache, forcing re-computation on next render */
139
+ resetLayoutCache(): void {
140
+ this.lastLayoutResult = null;
141
+ this.lastLayoutWidth = 0;
142
+ this.layoutDirty = true;
143
+ }
144
+
145
+ /**
146
+ * Compute responsive layout for the given width.
147
+ * Segments that don't fit in the top row overflow to the secondary row.
148
+ */
149
+ computeLayout(width: number): { topContent: string; secondaryContent: string } {
150
+ // Return cached layout if still valid
151
+ const now = Date.now();
152
+ if (this.lastLayoutResult && this.lastLayoutWidth === width && !this.layoutDirty && now - this.lastLayoutTimestamp < 5000) {
153
+ return this.lastLayoutResult;
154
+ }
155
+
156
+ const presetDef = getPreset(this.presetName);
157
+ const colors = presetDef.colors ?? getDefaultColors();
158
+
159
+ // Render all segments
160
+ const allSegmentIds = [
161
+ ...presetDef.leftSegments,
162
+ ...presetDef.rightSegments,
163
+ ...presetDef.secondarySegments,
164
+ ];
165
+
166
+ const renderedSegments: RenderedSegmentWithWidth[] = [];
167
+ for (const segId of allSegmentIds) {
168
+ if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) continue;
169
+
170
+ const segment = this.segmentLookup.get(segId);
171
+ if (!segment) continue;
172
+
173
+ const ctx: FooterSegmentContext = {
174
+ theme: this.getThemeLike(),
175
+ colors,
176
+ data: this.registry.getGroupData(this.getGroupForSegment(segId)),
177
+ width,
178
+ piContext: this.piContext,
179
+ footerData: this.footerData,
180
+ };
181
+
182
+ const rendered = segment.render(ctx);
183
+ if (!rendered.visible || !rendered.content) continue;
184
+
185
+ renderedSegments.push({
186
+ content: rendered.content,
187
+ width: visibleWidth(rendered.content),
188
+ visible: true,
189
+ });
190
+ }
191
+
192
+ if (renderedSegments.length === 0) {
193
+ this.lastLayoutResult = { topContent: "", secondaryContent: "" };
194
+ this.lastLayoutWidth = width;
195
+ this.lastLayoutTimestamp = now;
196
+ this.layoutDirty = false;
197
+ return this.lastLayoutResult;
198
+ }
199
+
200
+ // Separate primary (left+right) from secondary
201
+ const primaryIds = new Set([...presetDef.leftSegments, ...presetDef.rightSegments]);
202
+ const primarySegments: RenderedSegmentWithWidth[] = [];
203
+ const secondarySegments: RenderedSegmentWithWidth[] = [];
204
+
205
+ for (const seg of renderedSegments) {
206
+ // Check if this segment's content matches a primary or secondary segment
207
+ // We'll do a simpler approach: fit what fits in top row, overflow to secondary
208
+ primarySegments.push(seg);
209
+ }
210
+
211
+ // Compute responsive layout
212
+ const sepDef = getSeparator(presetDef.separator);
213
+ const sepWidth = visibleWidth(sepDef.left) + 2; // separator + spaces
214
+
215
+ const baseOverhead = 2; // leading + trailing space
216
+ let currentWidth = baseOverhead;
217
+ let topParts: string[] = [];
218
+ let overflowParts: RenderedSegmentWithWidth[] = [];
219
+ let overflow = false;
220
+
221
+ for (const seg of primarySegments) {
222
+ const needed = seg.width + (topParts.length > 0 ? sepWidth : 0);
223
+ if (!overflow && currentWidth + needed <= width) {
224
+ topParts.push(seg.content);
225
+ currentWidth += needed;
226
+ } else {
227
+ overflow = true;
228
+ overflowParts.push(seg);
229
+ }
230
+ }
231
+
232
+ // Fit overflow into secondary row
233
+ let secondaryWidth = baseOverhead;
234
+ let secondaryParts: string[] = [];
235
+ for (const seg of overflowParts) {
236
+ const needed = seg.width + (secondaryParts.length > 0 ? sepWidth : 0);
237
+ if (secondaryWidth + needed <= width) {
238
+ secondaryParts.push(seg.content);
239
+ secondaryWidth += needed;
240
+ } else {
241
+ break; // Stop at first non-fitting segment
242
+ }
243
+ }
244
+
245
+ const topContent = this.buildContentFromParts(topParts, sepDef);
246
+ const secondaryContent = this.buildContentFromParts(secondaryParts, sepDef);
247
+
248
+ this.lastLayoutResult = { topContent, secondaryContent };
249
+ this.lastLayoutWidth = width;
250
+ this.lastLayoutTimestamp = now;
251
+ this.layoutDirty = false;
252
+
253
+ return this.lastLayoutResult;
254
+ }
255
+
256
+ // ─── Helpers ─────────────────────────────────────────────────────────────
257
+
258
+ private buildContentFromParts(parts: string[], sepDef: { left: string }): string {
259
+ if (parts.length === 0) return "";
260
+ const sep = sepDef.left;
261
+ const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
262
+ return " " + parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `) + ANSI_RESET + " ";
263
+ }
264
+
265
+ /** Map a segment ID to its group ID */
266
+ private getGroupForSegment(segId: string): string {
267
+ // Core segments
268
+ const coreIds = ["model", "thinking", "path", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time"];
269
+ if (coreIds.includes(segId)) return "core";
270
+
271
+ // Compactor segments
272
+ const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "indexed_docs", "sandbox_runs", "search_queries"];
273
+ if (compactorIds.includes(segId)) return "compactor";
274
+
275
+ // Memory segments
276
+ if (["project_count", "total_count", "consolidations"].includes(segId)) return "memory";
277
+
278
+ // MCP segments
279
+ if (["servers_total", "servers_active", "tools_total", "servers_failed"].includes(segId)) return "mcp";
280
+
281
+ // Ralph segments
282
+ if (["active_loops", "total_iterations", "loop_status"].includes(segId)) return "ralph";
283
+
284
+ // Workflow segments
285
+ if (["current_command", "sandbox_level", "command_duration"].includes(segId)) return "workflow";
286
+
287
+ // Kanboard segments
288
+ if (["docs_count", "tasks_done", "tasks_total", "task_pct"].includes(segId)) return "kanboard";
289
+
290
+ // Notify segments
291
+ if (["platforms_enabled", "last_sent"].includes(segId)) return "notify";
292
+
293
+ // Status extension
294
+ if (segId === "extension_statuses") return "status_ext";
295
+
296
+ return "core";
297
+ }
298
+
299
+ /** Get a ThemeLike object for rendering context */
300
+ private getThemeLike(): { fg: (color: string, text: string) => string } {
301
+ // Use a minimal theme-like that applies ANSI codes based on color names
302
+ // The real theme is passed via setWidget callback
303
+ return {
304
+ fg: (color: string, text: string) => {
305
+ // Return text as-is; actual theming applied by segment renderers
306
+ return text;
307
+ },
308
+ };
309
+ }
310
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @pi-unipi/footer — Separator rendering
3
+ *
4
+ * Provides separator glyphs for all supported styles.
5
+ * Uses Nerd Font characters when available, ASCII fallbacks otherwise.
6
+ */
7
+
8
+ import type { SeparatorDef, SeparatorStyle } from "../types.js";
9
+
10
+ // ─── Nerd Font separator glyphs ─────────────────────────────────────────────
11
+
12
+ const NERD_SEPARATORS = {
13
+ powerlineLeft: "\uE0B0", //
14
+ powerlineRight: "\uE0B2", //
15
+ powerlineThinLeft: "\uE0B1", //
16
+ powerlineThinRight: "\uE0B3", //
17
+ slash: "/",
18
+ pipe: "|",
19
+ dot: "\u00B7", // ·
20
+ asciiLeft: ">",
21
+ asciiRight: "<",
22
+ } as const;
23
+
24
+ // ─── ASCII fallback separator glyphs ────────────────────────────────────────
25
+
26
+ const ASCII_SEPARATORS = {
27
+ powerlineLeft: ">",
28
+ powerlineRight: "<",
29
+ powerlineThinLeft: "|",
30
+ powerlineThinRight: "|",
31
+ slash: "/",
32
+ pipe: "|",
33
+ dot: ".",
34
+ asciiLeft: ">",
35
+ asciiRight: "<",
36
+ } as const;
37
+
38
+ // ─── Nerd Font detection ────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Detect whether the current terminal likely supports Nerd Font glyphs.
42
+ * Checks TERM_PROGRAM for known terminals and optional env override.
43
+ */
44
+ export function detectNerdFontSupport(): boolean {
45
+ // Explicit overrides
46
+ if (process.env.POWERLINE_NERD_FONTS === "1") return true;
47
+ if (process.env.POWERLINE_NERD_FONTS === "0") return false;
48
+
49
+ // Ghostty exposes GHOSTTY_RESOURCES_DIR even inside tmux
50
+ if (process.env.GHOSTTY_RESOURCES_DIR) return true;
51
+
52
+ // Check common terminals known to ship/bundle Nerd Fonts
53
+ const term = (process.env.TERM_PROGRAM || "").toLowerCase();
54
+ const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty"];
55
+ return nerdTerms.some(t => term.includes(t));
56
+ }
57
+
58
+ /**
59
+ * Get the separator character set based on terminal capabilities.
60
+ */
61
+ function getSeparatorChars() {
62
+ return detectNerdFontSupport() ? NERD_SEPARATORS : ASCII_SEPARATORS;
63
+ }
64
+
65
+ // ─── Separator map ──────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Get separator definition for the given style.
69
+ * Returns left and right glyph strings.
70
+ */
71
+ export function getSeparator(style: SeparatorStyle): SeparatorDef {
72
+ const chars = getSeparatorChars();
73
+
74
+ switch (style) {
75
+ case "powerline":
76
+ return {
77
+ left: chars.powerlineLeft,
78
+ right: chars.powerlineRight,
79
+ };
80
+
81
+ case "powerline-thin":
82
+ return {
83
+ left: chars.powerlineThinLeft,
84
+ right: chars.powerlineThinRight,
85
+ };
86
+
87
+ case "slash":
88
+ return { left: ` ${chars.slash} `, right: ` ${chars.slash} ` };
89
+
90
+ case "pipe":
91
+ return { left: ` ${chars.pipe} `, right: ` ${chars.pipe} ` };
92
+
93
+ case "dot":
94
+ return { left: ` ${chars.dot} `, right: ` ${chars.dot} ` };
95
+
96
+ case "ascii":
97
+ return { left: ` ${chars.asciiLeft} `, right: ` ${chars.asciiRight} ` };
98
+
99
+ default:
100
+ return getSeparator("powerline-thin");
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get the visible width of a separator (ANSI-stripped length).
106
+ */
107
+ export function separatorVisibleWidth(style: SeparatorStyle): number {
108
+ const sep = getSeparator(style);
109
+ // Strip ANSI codes and measure
110
+ const stripped = sep.left.replace(/\x1b\[[0-9;]*m/g, "");
111
+ return stripped.length;
112
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @pi-unipi/footer — Theme color resolution
3
+ *
4
+ * Maps semantic color names to pi theme colors. Supports
5
+ * hex overrides via ColorScheme.
6
+ */
7
+
8
+ import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
9
+ import type { ColorScheme, ColorValue, SemanticColor, ThemeLike } from "../types.js";
10
+
11
+ /** Default semantic-to-theme-color mapping */
12
+ const DEFAULT_COLOR_MAP: Record<SemanticColor, ThemeColor> = {
13
+ model: "accent",
14
+ path: "text",
15
+ git: "accent",
16
+ compactor: "muted",
17
+ memory: "accent",
18
+ mcp: "success",
19
+ ralph: "warning",
20
+ ralphOn: "success",
21
+ ralphOff: "error",
22
+ workflow: "accent",
23
+ workflowBrainstorm: "warning",
24
+ workflowPlan: "success",
25
+ workflowWork: "accent",
26
+ workflowReview: "muted",
27
+ workflowAuto: "thinkingHigh",
28
+ workflowOther: "dim",
29
+ kanboard: "dim",
30
+ notify: "muted",
31
+ separator: "dim",
32
+ border: "dim",
33
+ context: "muted",
34
+ contextWarn: "warning",
35
+ contextError: "error",
36
+ cost: "text",
37
+ tokens: "muted",
38
+ thinking: "accent",
39
+ thinkingMinimal: "thinkingMinimal",
40
+ thinkingLow: "thinkingLow",
41
+ thinkingMedium: "thinkingMedium",
42
+ thinkingHigh: "thinkingHigh",
43
+ thinkingXhigh: "thinkingXhigh",
44
+ };
45
+
46
+ /**
47
+ * Get the default color scheme mapping semantic names to theme colors.
48
+ */
49
+ export function getDefaultColors(): ColorScheme {
50
+ const scheme: ColorScheme = {};
51
+ for (const [key, value] of Object.entries(DEFAULT_COLOR_MAP)) {
52
+ scheme[key as SemanticColor] = value;
53
+ }
54
+ return scheme;
55
+ }
56
+
57
+ /**
58
+ * Resolve a ColorValue to an actual color string using the theme.
59
+ * If the value is a theme color name, uses theme.fg().
60
+ * If it's a hex string, returns it directly.
61
+ */
62
+ export function resolveColor(color: ColorValue, theme: ThemeLike): string {
63
+ // Check if it's a hex color (starts with #)
64
+ if (color.startsWith("#")) {
65
+ return color;
66
+ }
67
+ // It's a ThemeColor — use theme.fg
68
+ return theme.fg(color as ThemeColor, "").replace(/\x1b\[0m$/, "");
69
+ }
70
+
71
+ /**
72
+ * Apply a semantic color to text using the theme.
73
+ * Falls back to the default theme color if no override is provided.
74
+ */
75
+ export function applyColor(
76
+ semantic: SemanticColor,
77
+ text: string,
78
+ theme: ThemeLike,
79
+ colors: ColorScheme,
80
+ ): string {
81
+ const colorValue = colors[semantic];
82
+ if (!colorValue) {
83
+ // Use default from the map
84
+ const defaultColor = DEFAULT_COLOR_MAP[semantic] || "text";
85
+ return theme.fg(defaultColor as ThemeColor, text);
86
+ }
87
+
88
+ if (colorValue.startsWith("#")) {
89
+ // Hex color — we need to emit ANSI directly
90
+ const hex = colorValue.slice(1);
91
+ const r = Number.parseInt(hex.slice(0, 2), 16);
92
+ const g = Number.parseInt(hex.slice(2, 4), 16);
93
+ const b = Number.parseInt(hex.slice(4, 6), 16);
94
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
95
+ }
96
+
97
+ return theme.fg(colorValue as ThemeColor, text);
98
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @pi-unipi/footer — Compactor segments
3
+ *
4
+ * Segment renderers for the compactor group: session_events, compactions,
5
+ * tokens_saved, compression_ratio, indexed_docs, sandbox_runs, search_queries.
6
+ *
7
+ * Data sourced from piContext.sessionManager (live session data).
8
+ * Segments without a reliable data source are hidden (visible: false)
9
+ * rather than showing a placeholder like "—".
10
+ */
11
+
12
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
13
+ import { applyColor } from "../rendering/theme.js";
14
+ import { getIcon } from "../rendering/icons.js";
15
+
16
+ function withIcon(segmentId: string, text: string): string {
17
+ const icon = getIcon(segmentId);
18
+ return icon ? `${icon} ${text}` : text;
19
+ }
20
+
21
+ function formatTokens(n: number): string {
22
+ if (n < 1000) return n.toString();
23
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
24
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
25
+ return `${(n / 1000000).toFixed(1)}M`;
26
+ }
27
+
28
+ /** Hidden segment — no reliable data source available */
29
+ function hidden(): RenderedSegment {
30
+ return { content: "", visible: false };
31
+ }
32
+
33
+ /** Safely extract sessionManager from piContext */
34
+ function getSessionManager(ctx: FooterSegmentContext): any {
35
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
36
+ return piCtx?.sessionManager as any | undefined;
37
+ }
38
+
39
+ /** Get all session events from sessionManager branch */
40
+ function getSessionEvents(ctx: FooterSegmentContext): any[] {
41
+ const sm = getSessionManager(ctx);
42
+ if (!sm || typeof sm.getBranch !== "function") return [];
43
+ try {
44
+ return sm.getBranch() ?? [];
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function renderSessionEventsSegment(ctx: FooterSegmentContext): RenderedSegment {
51
+ const events = getSessionEvents(ctx);
52
+ const count = events.length;
53
+ if (count === 0) return hidden();
54
+
55
+ const content = withIcon("sessionEvents", `${count}`);
56
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
57
+ }
58
+
59
+ function renderCompactionsSegment(ctx: FooterSegmentContext): RenderedSegment {
60
+ // Count compaction entries in the session events
61
+ const events = getSessionEvents(ctx);
62
+ let compactionCount = 0;
63
+ for (const e of events) {
64
+ if (!e || typeof e !== "object") continue;
65
+ if (e.type === "compaction" || e.type === "compacted") {
66
+ compactionCount++;
67
+ }
68
+ }
69
+ if (compactionCount === 0) return hidden();
70
+
71
+ const content = withIcon("compactions", `${compactionCount}`);
72
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
73
+ }
74
+
75
+ function renderTokensSavedSegment(ctx: FooterSegmentContext): RenderedSegment {
76
+ // Sum tokens saved from compaction events if available
77
+ const events = getSessionEvents(ctx);
78
+ let tokensSaved = 0;
79
+ let hasCompaction = false;
80
+ for (const e of events) {
81
+ if (!e || typeof e !== "object") continue;
82
+ if (e.type === "compaction" || e.type === "compacted") {
83
+ hasCompaction = true;
84
+ tokensSaved += Number(e.tokensSaved ?? e.tokens_saved ?? 0);
85
+ }
86
+ }
87
+ if (!hasCompaction || tokensSaved === 0) return hidden();
88
+
89
+ const content = withIcon("tokensSaved", formatTokens(tokensSaved));
90
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
91
+ }
92
+
93
+ function renderCompressionRatioSegment(ctx: FooterSegmentContext): RenderedSegment {
94
+ // Check last compaction event for compression ratio
95
+ const events = getSessionEvents(ctx);
96
+ let lastRatio: number | undefined;
97
+ for (const e of events) {
98
+ if (!e || typeof e !== "object") continue;
99
+ if (e.type === "compaction" || e.type === "compacted") {
100
+ const ratio = e.compressionRatio ?? e.compression_ratio;
101
+ if (ratio !== undefined && ratio !== null) {
102
+ lastRatio = Number(ratio);
103
+ }
104
+ }
105
+ }
106
+ if (lastRatio === undefined) return hidden();
107
+
108
+ const content = withIcon("compressionRatio", `${lastRatio.toFixed(1)}x`);
109
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
110
+ }
111
+
112
+ function renderIndexedDocsSegment(_ctx: FooterSegmentContext): RenderedSegment {
113
+ // No reliable data source for indexed docs count
114
+ return hidden();
115
+ }
116
+
117
+ function renderSandboxRunsSegment(_ctx: FooterSegmentContext): RenderedSegment {
118
+ // No reliable data source for sandbox run count
119
+ return hidden();
120
+ }
121
+
122
+ function renderSearchQueriesSegment(_ctx: FooterSegmentContext): RenderedSegment {
123
+ // No reliable data source for search query count
124
+ return hidden();
125
+ }
126
+
127
+ export const COMPACTOR_SEGMENTS: FooterSegment[] = [
128
+ { id: "session_events", label: "Session Events", icon: "", render: renderSessionEventsSegment, defaultShow: true },
129
+ { id: "compactions", label: "Compactions", icon: "", render: renderCompactionsSegment, defaultShow: true },
130
+ { id: "tokens_saved", label: "Tokens Saved", icon: "", render: renderTokensSavedSegment, defaultShow: true },
131
+ { id: "compression_ratio", label: "Compression Ratio", icon: "", render: renderCompressionRatioSegment, defaultShow: false },
132
+ { id: "indexed_docs", label: "Indexed Docs", icon: "", render: renderIndexedDocsSegment, defaultShow: false },
133
+ { id: "sandbox_runs", label: "Sandbox Runs", icon: "", render: renderSandboxRunsSegment, defaultShow: false },
134
+ { id: "search_queries", label: "Search Queries", icon: "", render: renderSearchQueriesSegment, defaultShow: false },
135
+ ];