@pi-unipi/footer 0.1.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/help.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @pi-unipi/footer — Footer help overlay
3
+ *
4
+ * Shows an overlay listing all enabled segments grouped by zone,
5
+ * with icons, short labels, and descriptions.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import type { FooterSegment, SegmentZone } from "./types.js";
10
+ import { getIcon } from "./rendering/icons.js";
11
+ import { loadFooterSettings, isSegmentEnabled } from "./config.js";
12
+ import { getPreset } from "./presets.js";
13
+
14
+ /** Zone display names and order */
15
+ const ZONE_META: Record<SegmentZone, { title: string; order: number }> = {
16
+ left: { title: "LEFT ZONE (Identity)", order: 0 },
17
+ center: { title: "CENTER ZONE (Metrics)", order: 1 },
18
+ right: { title: "RIGHT ZONE (Time)", order: 2 },
19
+ };
20
+
21
+ /** Build the help content lines */
22
+ function buildHelpLines(
23
+ segments: FooterSegment[],
24
+ presetName: string,
25
+ ): string[] {
26
+ const settings = loadFooterSettings();
27
+ const preset = getPreset(presetName);
28
+ const enabledIds = new Set([
29
+ ...preset.leftSegments,
30
+ ...preset.rightSegments,
31
+ ...preset.secondarySegments,
32
+ ]);
33
+
34
+ // Filter to enabled segments only
35
+ const enabled = segments.filter(seg => {
36
+ if (!enabledIds.has(seg.id)) return false;
37
+ return isSegmentEnabled(getGroupForSegment(seg.id), seg.id);
38
+ });
39
+
40
+ if (enabled.length === 0) {
41
+ return ["No segments enabled."];
42
+ }
43
+
44
+ // Group by zone
45
+ const zones: Record<SegmentZone, FooterSegment[]> = { left: [], center: [], right: [] };
46
+ for (const seg of enabled) {
47
+ zones[seg.zone].push(seg);
48
+ }
49
+
50
+ const lines: string[] = [];
51
+
52
+ for (const zoneKey of (["left", "center", "right"] as SegmentZone[])) {
53
+ const zoneSegs = zones[zoneKey];
54
+ if (zoneSegs.length === 0) continue;
55
+
56
+ const meta = ZONE_META[zoneKey];
57
+ lines.push(` ${meta.title}`);
58
+ lines.push("");
59
+
60
+ for (const seg of zoneSegs) {
61
+ const icon = getIcon(seg.id);
62
+ const label = seg.shortLabel;
63
+ const desc = seg.description;
64
+ const iconStr = icon ? `${icon} ` : " ";
65
+ lines.push(` ${iconStr}${label.padEnd(6)} ${desc}`);
66
+ }
67
+
68
+ lines.push("");
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
74
+ /** Simple group lookup for help */
75
+ function getGroupForSegment(segId: string): string {
76
+ const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time", "tps", "clock", "duration", "thinking_level"];
77
+ if (coreIds.includes(segId)) return "core";
78
+ const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "cocoindex_status", "sandbox_runs", "search_queries"];
79
+ if (compactorIds.includes(segId)) return "compactor";
80
+ if (["project_count", "total_count", "consolidations"].includes(segId)) return "memory";
81
+ if (["servers_total", "servers_active", "tools_total", "servers_failed"].includes(segId)) return "mcp";
82
+ if (["active_loops", "total_iterations", "loop_status"].includes(segId)) return "ralph";
83
+ if (["current_command", "sandbox_level", "command_duration"].includes(segId)) return "workflow";
84
+ if (["docs_count", "tasks_done", "tasks_total", "task_pct"].includes(segId)) return "kanboard";
85
+ if (["platforms_enabled", "last_sent"].includes(segId)) return "notify";
86
+ if (segId === "extension_statuses") return "status_ext";
87
+ return "core";
88
+ }
89
+
90
+ /**
91
+ * Show the footer help overlay.
92
+ * Lists all enabled segments grouped by zone with descriptions.
93
+ */
94
+ export function showFooterHelp(
95
+ pi: ExtensionAPI,
96
+ segments: FooterSegment[],
97
+ presetName: string,
98
+ ): void {
99
+ const lines = buildHelpLines(segments, presetName);
100
+
101
+ // Use pi's custom UI overlay
102
+ const ctx = (pi as any)._ctx;
103
+ if (ctx?.ui?.custom) {
104
+ ctx.ui.custom((tui: import("@mariozechner/pi-tui").TUI) => {
105
+ let scrollOffset = 0;
106
+
107
+ return {
108
+ dispose() {},
109
+ render(width: number, height: number): string[] {
110
+ const maxVisible = height - 2; // border lines
111
+ const visibleLines = lines.slice(scrollOffset, scrollOffset + maxVisible);
112
+
113
+ const result: string[] = [];
114
+
115
+ // Top border
116
+ const title = " ? Footer Segment Guide ";
117
+ const borderLen = Math.max(width - 2, title.length + 4);
118
+ result.push(`\x1b[2m┌${"─".repeat(borderLen)}┐\x1b[0m`);
119
+
120
+ // Title
121
+ result.push(`\x1b[2m│\x1b[0m \x1b[1m${title}\x1b[0m${" ".repeat(Math.max(0, borderLen - title.length - 1))}\x1b[2m│\x1b[0m`);
122
+
123
+ // Content
124
+ for (const line of visibleLines) {
125
+ const padded = line.length > borderLen - 2
126
+ ? line.slice(0, borderLen - 2)
127
+ : line + " ".repeat(Math.max(0, borderLen - 2 - line.length));
128
+ result.push(`\x1b[2m│\x1b[0m ${padded} \x1b[2m│\x1b[0m`);
129
+ }
130
+
131
+ // Bottom border
132
+ result.push(`\x1b[2m├${"─".repeat(borderLen)}┤\x1b[0m`);
133
+ result.push(`\x1b[2m│\x1b[0m \x1b[2m↑↓ scroll · q close\x1b[0m${" ".repeat(Math.max(0, borderLen - 20))} \x1b[2m│\x1b[0m`);
134
+ result.push(`\x1b[2m└${"─".repeat(borderLen)}┘\x1b[0m`);
135
+
136
+ return result;
137
+ },
138
+ handleInput(key: string): boolean {
139
+ if (key === "q" || key === "Escape" || key === "Enter") {
140
+ return false; // Close overlay
141
+ }
142
+ if (key === "ArrowUp" || key === "k") {
143
+ scrollOffset = Math.max(0, scrollOffset - 1);
144
+ return true;
145
+ }
146
+ if (key === "ArrowDown" || key === "j") {
147
+ scrollOffset = Math.min(Math.max(0, lines.length - 5), scrollOffset + 1);
148
+ return true;
149
+ }
150
+ return true; // Consume all other keys
151
+ },
152
+ };
153
+ });
154
+ } else {
155
+ // Fallback: print to console
156
+ for (const line of lines) {
157
+ console.log(line);
158
+ }
159
+ }
160
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * initializes renderer on session_start.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionAPI, Theme, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
9
10
  import { UNIPI_EVENTS, emitEvent, UNIPI_PREFIX, FOOTER_COMMANDS } from "@pi-unipi/core";
10
11
  import { FooterRegistry, getFooterRegistry } from "./registry/index.js";
11
12
  import { FooterRenderer } from "./rendering/renderer.js";
@@ -27,6 +28,7 @@ import { STATUS_EXT_SEGMENTS } from "./segments/status-ext.js";
27
28
 
28
29
  import type { FooterGroup, FooterSegment } from "./types.js";
29
30
  import { rainbowBorder } from "./segments/core.js";
31
+ import { tpsTracker } from "./tps-tracker.js";
30
32
 
31
33
  /** All segment groups */
32
34
  const ALL_GROUPS: FooterGroup[] = [
@@ -61,10 +63,10 @@ export interface FooterState {
61
63
  unsubscribeEvents: (() => void) | null;
62
64
  piContext: unknown;
63
65
  footerData: unknown;
64
- tuiRef: any;
66
+ tuiRef: import("@mariozechner/pi-tui").TUI | null | undefined;
65
67
  refreshTimer: ReturnType<typeof setInterval> | null;
66
68
  /** Re-register footer + widgets with pi UI (for live enable) */
67
- setupUI: ((pi: ExtensionAPI, ctx: any) => void) | null;
69
+ setupUI: ((pi: ExtensionAPI, ctx: ExtensionContext) => void) | null;
68
70
  }
69
71
 
70
72
  export default function footerExtension(pi: ExtensionAPI): void {
@@ -77,7 +79,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
77
79
  registry: getFooterRegistry(),
78
80
  renderer: new FooterRenderer(
79
81
  getFooterRegistry(),
80
- { get: (id: string) => segmentLookup.get(id) },
82
+ { get: (id: string) => segmentLookup.get(id), allIds: () => Array.from(segmentLookup.keys()) },
81
83
  loadFooterSettings().preset,
82
84
  ),
83
85
  segmentLookup,
@@ -110,7 +112,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
110
112
 
111
113
  // Setup footer + widgets
112
114
  setupFooterUI(pi, ctx, state);
113
- state.setupUI = (p: ExtensionAPI, c: any) => setupFooterUI(p, c, state);
115
+ state.setupUI = (p: ExtensionAPI, c: ExtensionContext) => setupFooterUI(p, c, state);
114
116
  });
115
117
 
116
118
  pi.on("session_shutdown", async () => {
@@ -124,6 +126,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
124
126
  state.refreshTimer = null;
125
127
  }
126
128
  state.tuiRef = null;
129
+ tpsTracker.reset();
127
130
  });
128
131
 
129
132
  // ─── Register commands ──────────────────────────────────────────────────
@@ -133,10 +136,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
133
136
  // ─── Emit MODULE_READY ──────────────────────────────────────────────────
134
137
 
135
138
  pi.on("session_start", async () => {
136
- emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
139
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
137
140
  name: "@pi-unipi/footer",
138
141
  version: "0.1.0",
139
- commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`],
142
+ commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
140
143
  tools: [],
141
144
  });
142
145
  });
@@ -144,14 +147,36 @@ export default function footerExtension(pi: ExtensionAPI): void {
144
147
 
145
148
  // ─── Footer UI setup ────────────────────────────────────────────────────────
146
149
 
147
- function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
150
+ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterState): void {
148
151
  // Register footer (minimal — handles branch changes)
149
- ctx.ui.setFooter((tui: any, _theme: Theme, footerData: any) => {
152
+ ctx.ui.setFooter((tui, _theme, footerData) => {
150
153
  state.tuiRef = tui;
151
154
 
152
155
  // Start periodic refresh for time-sensitive segments (e.g. clock)
153
156
  if (!state.refreshTimer) {
154
157
  state.refreshTimer = setInterval(() => {
158
+ // Feed TPS tracker with per-message data
159
+ try {
160
+ const piCtx = state.piContext as Record<string, unknown> | undefined;
161
+ if (piCtx?.sessionManager) {
162
+ const sm = (piCtx as any).sessionManager;
163
+ const events = sm?.getBranch?.() ?? [];
164
+ let msgIndex = 0;
165
+ for (const e of events) {
166
+ if (!e || typeof e !== "object") continue;
167
+ if (e.type !== "message") continue;
168
+ const m = e.message;
169
+ if (!m || m.role !== "assistant") continue;
170
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
171
+ const output = m.usage?.output ?? 0;
172
+ const hasStop = !!m.stopReason;
173
+ tpsTracker.onMessageUpdate(msgIndex, output, hasStop);
174
+ msgIndex++;
175
+ }
176
+ }
177
+ } catch {
178
+ // Silently ignore — TPS is best-effort
179
+ }
155
180
  state.renderer.resetLayoutCache();
156
181
  state.tuiRef?.requestRender();
157
182
  }, 1_000);
@@ -175,7 +200,7 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
175
200
  });
176
201
 
177
202
  // Top row widget
178
- ctx.ui.setWidget("footer-top", (_tui: any, theme: Theme) => {
203
+ ctx.ui.setWidget("footer-top", (_tui, theme) => {
179
204
  // Update the renderer's theme-like
180
205
  const themeLike = { fg: (color: string, text: string) => theme.fg(color as any, text) };
181
206
  // We need to patch the context with proper theme
@@ -187,30 +212,38 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
187
212
  state.renderer.resetLayoutCache();
188
213
  },
189
214
  render(width: number): string[] {
190
- if (!state.enabled || !state.piContext) return [];
215
+ if (!state.enabled || !state.piContext || width <= 0) return [];
191
216
 
192
217
  // Build layout with proper theme by creating segment contexts
193
218
  const layout = state.renderer.computeLayout(width);
194
- return layout.topContent ? [layout.topContent] : [];
219
+ if (!layout.topContent) return [];
220
+
221
+ // Hard safety net: never return a line wider than the terminal.
222
+ // This catches any edge cases in layout math or visibleWidth()
223
+ // inconsistencies with PUA characters + ANSI codes.
224
+ const line = layout.topContent;
225
+ return [visibleWidth(line) > width ? truncateToWidth(line, width) : line];
195
226
  },
196
227
  };
197
228
  }, { placement: "aboveEditor" });
198
229
 
199
230
  // Secondary row widget
200
- ctx.ui.setWidget("footer-secondary", (_tui: any, _theme: Theme) => {
231
+ ctx.ui.setWidget("footer-secondary", (_tui, _theme) => {
201
232
  return {
202
233
  dispose() {},
203
234
  invalidate() {
204
235
  state.renderer.resetLayoutCache();
205
236
  },
206
237
  render(width: number): string[] {
207
- if (!state.enabled || !state.piContext) return [];
238
+ if (!state.enabled || !state.piContext || width <= 0) return [];
208
239
 
209
240
  const lines: string[] = [];
210
241
 
211
242
  const layout = state.renderer.computeLayout(width);
212
243
  if (layout.secondaryContent) {
213
- lines.push(layout.secondaryContent);
244
+ // Hard safety net: never return a line wider than the terminal.
245
+ const line = layout.secondaryContent;
246
+ lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
214
247
  }
215
248
 
216
249
  return lines;
package/src/presets.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * @pi-unipi/footer — Presets system
3
3
  *
4
4
  * Preset definitions: default, minimal, compact, full, nerd, ascii.
5
- * Each preset defines which segments appear on left, right, and secondary rows,
5
+ * Each preset defines which segments appear (left/center/right/secondary),
6
6
  * plus separator style and color scheme.
7
+ *
8
+ * Segments are grouped by their zone field regardless of which array they're
9
+ * listed in. The arrays define ordering within the preset.
7
10
  */
8
11
 
9
12
  import type { PresetDef, SeparatorStyle, ColorScheme } from "./types.js";
@@ -12,100 +15,106 @@ import { getDefaultColors } from "./rendering/theme.js";
12
15
  /** Default preset — balanced view */
13
16
  const DEFAULT_PRESET: PresetDef = {
14
17
  leftSegments: [
15
- "model", "api_state", "tool_count", "git", "context_pct", "cost",
18
+ "model", "api_state", "tool_count", "git",
16
19
  ],
17
20
  rightSegments: [
18
- "compactions", "tokens_saved", "project_count", "loop_status",
21
+ "tps", "context_pct", "cost",
22
+ "compactions", "tokens_saved", "project_count",
23
+ "current_command", "loop_status", "extension_statuses",
24
+ "clock", "duration",
19
25
  ],
20
26
  secondarySegments: [
21
- "current_command", "session_events",
27
+ "session",
22
28
  ],
23
- separator: "powerline-thin",
24
29
  colors: getDefaultColors(),
25
30
  };
26
31
 
27
32
  /** Minimal preset — just the essentials */
28
33
  const MINIMAL_PRESET: PresetDef = {
29
34
  leftSegments: [
30
- "git", "context_pct",
35
+ "model", "git",
36
+ ],
37
+ rightSegments: [
38
+ "context_pct",
39
+ "clock",
31
40
  ],
32
- rightSegments: [],
33
41
  secondarySegments: [],
34
- separator: "pipe",
35
42
  colors: getDefaultColors(),
36
43
  };
37
44
 
38
45
  /** Compact preset — core + key stats */
39
46
  const COMPACT_PRESET: PresetDef = {
40
47
  leftSegments: [
41
- "model", "git", "cost", "context_pct",
48
+ "model", "git",
42
49
  ],
43
50
  rightSegments: [
44
- "compactions", "total_count",
51
+ "tps", "context_pct", "cost",
52
+ "clock", "duration",
45
53
  ],
46
54
  secondarySegments: [],
47
- separator: "dot",
48
55
  colors: getDefaultColors(),
49
56
  };
50
57
 
51
58
  /** Full preset — everything */
52
59
  const FULL_PRESET: PresetDef = {
53
60
  leftSegments: [
54
- "model", "api_state", "tool_count", "git", "context_pct", "cost",
55
- "tokens_total", "tokens_in", "tokens_out",
61
+ "model", "api_state", "tool_count", "git", "current_command", "session",
56
62
  ],
57
63
  rightSegments: [
58
- "session_events", "compactions", "tokens_saved", "compression_ratio",
59
- "indexed_docs", "sandbox_runs", "search_queries",
60
- "project_count", "total_count", "consolidations",
61
- "servers_total", "servers_active", "tools_total", "servers_failed",
62
- "active_loops", "total_iterations", "loop_status",
63
- "current_command", "command_duration",
64
- "docs_count", "tasks_done", "tasks_total", "task_pct",
64
+ "tps", "context_pct", "cost", "tokens_total",
65
+ "session_events", "compactions", "tokens_saved",
66
+ "project_count", "total_count",
67
+ "servers_total", "servers_active", "tools_total",
68
+ "active_loops", "loop_status",
69
+ "docs_count", "tasks_done", "task_pct",
65
70
  "extension_statuses",
71
+ "clock", "duration",
66
72
  ],
67
73
  secondarySegments: [
68
- "hostname", "time",
74
+ "hostname",
75
+ "tokens_in", "tokens_out",
76
+ "compression_ratio", "cocoindex_status",
69
77
  "platforms_enabled", "last_sent",
78
+ "thinking_level",
70
79
  ],
71
- separator: "powerline-thin",
72
80
  colors: getDefaultColors(),
73
81
  };
74
82
 
75
83
  /** Nerd preset — maximum detail for Nerd Font users */
76
84
  const NERD_PRESET: PresetDef = {
77
85
  leftSegments: [
78
- "model", "api_state", "tool_count", "git", "context_pct", "cost",
79
- "tokens_total",
86
+ "model", "api_state", "tool_count", "git", "current_command", "session",
80
87
  ],
81
88
  rightSegments: [
89
+ "tps", "context_pct", "cost", "tokens_total",
82
90
  "session_events", "compactions", "tokens_saved",
83
91
  "project_count", "total_count",
84
92
  "servers_total", "servers_active", "tools_total",
85
93
  "active_loops", "loop_status",
86
- "current_command",
87
- "docs_count", "tasks_done", "tasks_total", "task_pct",
94
+ "docs_count", "tasks_done", "task_pct",
88
95
  "extension_statuses",
96
+ "clock", "duration",
89
97
  ],
90
98
  secondarySegments: [
91
- "session", "hostname", "time",
92
- "compression_ratio", "indexed_docs",
99
+ "hostname",
100
+ "tokens_in", "tokens_out",
101
+ "compression_ratio", "cocoindex_status",
93
102
  "platforms_enabled", "last_sent",
103
+ "thinking_level",
94
104
  ],
95
- separator: "powerline",
96
105
  colors: getDefaultColors(),
97
106
  };
98
107
 
99
108
  /** ASCII preset — safe for any terminal */
100
109
  const ASCII_PRESET: PresetDef = {
101
110
  leftSegments: [
102
- "model", "git", "context_pct", "cost",
111
+ "model", "git",
103
112
  ],
104
113
  rightSegments: [
105
- "compactions", "tokens_saved", "project_count",
114
+ "tps", "context_pct", "cost",
115
+ "clock", "duration",
106
116
  ],
107
117
  secondarySegments: [],
108
- separator: "ascii",
109
118
  colors: getDefaultColors(),
110
119
  };
111
120
 
@@ -156,5 +156,5 @@ export function resetFooterRegistry(): void {
156
156
 
157
157
  // Expose on globalThis for cross-package access
158
158
  if (typeof globalThis !== "undefined") {
159
- (globalThis as Record<string, unknown>).__unipi_footer_registry = getFooterRegistry();
159
+ globalThis.__unipi_footer_registry = getFooterRegistry();
160
160
  }