@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,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
|
+
];
|