@pi-unipi/footer 0.1.4 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "0.1.4",
3
+ "version": "2.0.1",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * /unipi:footer-settings.
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
9
9
  import { UNIPI_PREFIX, FOOTER_COMMANDS } from "@pi-unipi/core";
10
10
  import { loadFooterSettings, saveFooterSettings } from "./config.js";
11
11
  import { showFooterSettings } from "./tui/settings-tui.js";
@@ -23,7 +23,7 @@ interface FooterState {
23
23
  };
24
24
  segmentLookup: Map<string, FooterSegment>;
25
25
  piContext: unknown;
26
- setupUI: ((pi: ExtensionAPI, ctx: any) => void) | null;
26
+ setupUI: ((pi: ExtensionAPI, ctx: ExtensionContext) => void) | null;
27
27
  }
28
28
 
29
29
  /**
package/src/config.ts CHANGED
@@ -144,6 +144,18 @@ export function isSegmentEnabled(groupId: string, segmentId: string): boolean {
144
144
  return true;
145
145
  }
146
146
 
147
+ /**
148
+ * Check if a segment is explicitly enabled by user settings (toggled on).
149
+ * Returns true only if the segment appears in the settings with value `true`.
150
+ * Segments that are enabled by default but not explicitly configured return false.
151
+ */
152
+ export function isSegmentExplicitlyEnabled(groupId: string, segmentId: string): boolean {
153
+ const settings = loadFooterSettings();
154
+ const groupSettings = settings.groups[groupId];
155
+ if (!groupSettings) return false;
156
+ return groupSettings.segments?.[segmentId] === true;
157
+ }
158
+
147
159
  // ─── Helpers ────────────────────────────────────────────────────────────────
148
160
 
149
161
  function isValidSeparator(value: unknown): boolean {
package/src/help.ts CHANGED
@@ -75,7 +75,7 @@ function buildHelpLines(
75
75
  function getGroupForSegment(segId: string): string {
76
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
77
  if (coreIds.includes(segId)) return "core";
78
- const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "indexed_docs", "sandbox_runs", "search_queries"];
78
+ const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "cocoindex_status", "sandbox_runs", "search_queries"];
79
79
  if (compactorIds.includes(segId)) return "compactor";
80
80
  if (["project_count", "total_count", "consolidations"].includes(segId)) return "memory";
81
81
  if (["servers_total", "servers_active", "tools_total", "servers_failed"].includes(segId)) return "mcp";
@@ -101,7 +101,7 @@ export function showFooterHelp(
101
101
  // Use pi's custom UI overlay
102
102
  const ctx = (pi as any)._ctx;
103
103
  if (ctx?.ui?.custom) {
104
- ctx.ui.custom((tui: any) => {
104
+ ctx.ui.custom((tui: import("@mariozechner/pi-tui").TUI) => {
105
105
  let scrollOffset = 0;
106
106
 
107
107
  return {
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";
@@ -62,10 +63,10 @@ export interface FooterState {
62
63
  unsubscribeEvents: (() => void) | null;
63
64
  piContext: unknown;
64
65
  footerData: unknown;
65
- tuiRef: any;
66
+ tuiRef: import("@mariozechner/pi-tui").TUI | null | undefined;
66
67
  refreshTimer: ReturnType<typeof setInterval> | null;
67
68
  /** Re-register footer + widgets with pi UI (for live enable) */
68
- setupUI: ((pi: ExtensionAPI, ctx: any) => void) | null;
69
+ setupUI: ((pi: ExtensionAPI, ctx: ExtensionContext) => void) | null;
69
70
  }
70
71
 
71
72
  export default function footerExtension(pi: ExtensionAPI): void {
@@ -78,7 +79,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
78
79
  registry: getFooterRegistry(),
79
80
  renderer: new FooterRenderer(
80
81
  getFooterRegistry(),
81
- { get: (id: string) => segmentLookup.get(id) },
82
+ { get: (id: string) => segmentLookup.get(id), allIds: () => Array.from(segmentLookup.keys()) },
82
83
  loadFooterSettings().preset,
83
84
  ),
84
85
  segmentLookup,
@@ -111,7 +112,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
111
112
 
112
113
  // Setup footer + widgets
113
114
  setupFooterUI(pi, ctx, state);
114
- state.setupUI = (p: ExtensionAPI, c: any) => setupFooterUI(p, c, state);
115
+ state.setupUI = (p: ExtensionAPI, c: ExtensionContext) => setupFooterUI(p, c, state);
115
116
  });
116
117
 
117
118
  pi.on("session_shutdown", async () => {
@@ -135,7 +136,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
135
136
  // ─── Emit MODULE_READY ──────────────────────────────────────────────────
136
137
 
137
138
  pi.on("session_start", async () => {
138
- emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
139
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
139
140
  name: "@pi-unipi/footer",
140
141
  version: "0.1.0",
141
142
  commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
@@ -146,9 +147,9 @@ export default function footerExtension(pi: ExtensionAPI): void {
146
147
 
147
148
  // ─── Footer UI setup ────────────────────────────────────────────────────────
148
149
 
149
- function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
150
+ function setupFooterUI(pi: ExtensionAPI, ctx: ExtensionContext, state: FooterState): void {
150
151
  // Register footer (minimal — handles branch changes)
151
- ctx.ui.setFooter((tui: any, _theme: Theme, footerData: any) => {
152
+ ctx.ui.setFooter((tui, _theme, footerData) => {
152
153
  state.tuiRef = tui;
153
154
 
154
155
  // Start periodic refresh for time-sensitive segments (e.g. clock)
@@ -199,7 +200,7 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
199
200
  });
200
201
 
201
202
  // Top row widget
202
- ctx.ui.setWidget("footer-top", (_tui: any, theme: Theme) => {
203
+ ctx.ui.setWidget("footer-top", (_tui, theme) => {
203
204
  // Update the renderer's theme-like
204
205
  const themeLike = { fg: (color: string, text: string) => theme.fg(color as any, text) };
205
206
  // We need to patch the context with proper theme
@@ -211,30 +212,38 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
211
212
  state.renderer.resetLayoutCache();
212
213
  },
213
214
  render(width: number): string[] {
214
- if (!state.enabled || !state.piContext) return [];
215
+ if (!state.enabled || !state.piContext || width <= 0) return [];
215
216
 
216
217
  // Build layout with proper theme by creating segment contexts
217
218
  const layout = state.renderer.computeLayout(width);
218
- 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];
219
226
  },
220
227
  };
221
228
  }, { placement: "aboveEditor" });
222
229
 
223
230
  // Secondary row widget
224
- ctx.ui.setWidget("footer-secondary", (_tui: any, _theme: Theme) => {
231
+ ctx.ui.setWidget("footer-secondary", (_tui, _theme) => {
225
232
  return {
226
233
  dispose() {},
227
234
  invalidate() {
228
235
  state.renderer.resetLayoutCache();
229
236
  },
230
237
  render(width: number): string[] {
231
- if (!state.enabled || !state.piContext) return [];
238
+ if (!state.enabled || !state.piContext || width <= 0) return [];
232
239
 
233
240
  const lines: string[] = [];
234
241
 
235
242
  const layout = state.renderer.computeLayout(width);
236
243
  if (layout.secondaryContent) {
237
- 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);
238
247
  }
239
248
 
240
249
  return lines;
package/src/presets.ts CHANGED
@@ -73,7 +73,7 @@ const FULL_PRESET: PresetDef = {
73
73
  secondarySegments: [
74
74
  "hostname",
75
75
  "tokens_in", "tokens_out",
76
- "compression_ratio", "indexed_docs",
76
+ "compression_ratio", "cocoindex_status",
77
77
  "platforms_enabled", "last_sent",
78
78
  "thinking_level",
79
79
  ],
@@ -98,7 +98,7 @@ const NERD_PRESET: PresetDef = {
98
98
  secondarySegments: [
99
99
  "hostname",
100
100
  "tokens_in", "tokens_out",
101
- "compression_ratio", "indexed_docs",
101
+ "compression_ratio", "cocoindex_status",
102
102
  "platforms_enabled", "last_sent",
103
103
  "thinking_level",
104
104
  ],
@@ -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
  }
@@ -84,65 +84,65 @@ export interface IconSet {
84
84
  /** Nerd Font glyphs — requires a Nerd Font installed in the terminal */
85
85
  export const NERD_ICONS: IconSet = {
86
86
  // Core
87
- model: "\uDB81\uDE5B", // 󰚩 custom model icon
88
- apiState: "\uF725", // 󱂛 api state icon
89
- toolCount: "\uF0AD", // tool count icon
90
- git: "\uF0E8", // git icon
91
- context: "\uF8D8", // context icon
92
- cost: "\uF155", // cost icon
93
- tokens: "\uF07B", // tokens icon
94
- tokensIn: "\uF07B", // tokens in icon
95
- tokensOut: "\uF07B", // tokens out icon
96
- session: "\uF550", // nf-md-identifier
97
- hostname: "\uF109", // nf-fa-laptop
98
- time: "\uF017", // nf-fa-clock_o
99
- tps: "\uF062", // \u2191 up arrow
100
- clock: "\uF017", // nf-fa-clock_o
101
- duration: "\uF49B", // nf-md-timer_outline
102
- thinkingLevel: "\uF4D8", // nf-fa-lightbulb_o
87
+ model: "\u{F06A9}", // 󰚩
88
+ apiState: "\u{F109B}", // 󱂛
89
+ toolCount: "\u{F1064}", // 󱁤
90
+ git: "\uEAFE", //
91
+ context: "\u{F0077}", // 󰁷
92
+ cost: "\uF155", //
93
+ tokens: "\uEDE8", //
94
+ tokensIn: "\uEDE8", //
95
+ tokensOut: "\uEDE8", //
96
+ session: "\uF03A", //
97
+ hostname: "\uEA7A", //
98
+ time: "\uF017", //
99
+ tps: "\u{F04C5}", // 󰓅
100
+ clock: "\uF017", //
101
+ duration: "\u{F13AB}", // 󱎫
102
+ thinkingLevel: "\uF400", //
103
103
 
104
104
  // Compactor
105
- sessionEvents: "\uDBB1\uDECF", // 󰲏 session events icon
106
- compactions: "\uDBB1\uDECF", // 󰲏 compactions icon
107
- tokensSaved: "\uF155", // tokens saved icon
108
- compressionRatio:"\uDBB1\uDECF", // 󰲏 compression ratio icon
109
- indexedDocs: "\uDB81\uDE19", // 󰈙 indexed docs icon
110
- sandboxRuns: "\uF121", // sandbox runs icon
111
- searchQueries: "\uF002", // search queries icon
105
+ sessionEvents: "\uEA86", //
106
+ compactions: "\u{F0C8F}", // 󰲏
107
+ tokensSaved: "\uF155", // (kept missing from customization)
108
+ compressionRatio:"\u{F0C8F}", // 󰲏
109
+ indexedDocs: "\u{F0219}", // 󰈙
110
+ sandboxRuns: "\uF233", //
111
+ searchQueries: "\uF002", //
112
112
 
113
113
  // Memory
114
- projectCount: "\uDB81\uDED4", // 󰍚 memory icon
115
- totalCount: "\uEB9C", // total count icon
116
- consolidations: "\uDB81\uDED4", // 󰍚 consolidations icon
114
+ projectCount: "\uEE9C", //
115
+ totalCount: "\uEE9C", //
116
+ consolidations: "\uEE9C", //
117
117
 
118
118
  // MCP
119
- serversTotal: "\uF0F6", // servers total icon
120
- serversActive: "\uF058", // servers active icon
121
- toolsTotal: "\uF0AD", // tools total icon
122
- serversFailed: "\uF467", // servers failed icon
119
+ serversTotal: "\u{F05B7}", // 󰖷
120
+ serversActive: "\u{F05B7}", // 󰖷
121
+ toolsTotal: "\u{F05B7}", // 󰖷
122
+ serversFailed: "\u{F05B7}", // 󰖷
123
123
 
124
124
  // Ralph
125
- activeLoops: "\udb81\udf09", // 󰼉 ralph loop icon
126
- totalIterations: "\udb81\udf09", // 󰼉 ralph loop icon
127
- loopStatus: "\udb81\udf09", // 󰼉 ralph loop icon
125
+ activeLoops: "\u{F0709}", // 󰜉
126
+ totalIterations: "\u{F0709}", // 󰜉
127
+ loopStatus: "\u{F0709}", // 󰜉
128
128
 
129
129
  // Workflow
130
- currentCommand: "\uF0E8", // current command icon
131
- sandboxLevel: "\uDBB1\uDDFE", // 󰟾 sandbox level icon
132
- commandDuration: "\uDBB9\uDEAB", // 󱎫 command duration icon
130
+ currentCommand: "\uF124", //
131
+ sandboxLevel: "\u{F07FE}", // 󰟾
132
+ commandDuration: "\u{F13AB}", // 󱎫
133
133
 
134
134
  // Kanboard
135
- docsCount: "\uDB81\uDE19", // 󰈙 docs count icon
136
- tasksDone: "\uF0E8", // tasks done icon
137
- tasksTotal: "\uF0E8", // tasks total icon
138
- taskPct: "\uF0E8", // task pct icon
135
+ docsCount: "\u{F09EE}", // 󰧮
136
+ tasksDone: "\u{F1A9A}", // 󱪚
137
+ tasksTotal: "\uF4A0", //
138
+ taskPct: "\uF4A0", //
139
139
 
140
140
  // Notify
141
- platformsEnabled:"\uF0E0", // nf-fa-envelope
142
- lastSent: "\uF017", // nf-fa-clock_o
141
+ platformsEnabled:"\uEB9A", //
142
+ lastSent: "\u{F13AB}", // 󱎫
143
143
 
144
144
  // Extension status
145
- extensionStatuses:"\uDBB5\uDEAB", // 󱖫 extension statuses icon
145
+ extensionStatuses:"\u{F15AB}", // 󱖫
146
146
 
147
147
  separator: "\uE0B1", // nf-pl-left_soft_divider
148
148
  };
@@ -14,11 +14,13 @@ import { getSeparator, separatorVisibleWidth } from "./separators.js";
14
14
  import { getDefaultColors } from "./theme.js";
15
15
  import { setIconStyle } from "./icons.js";
16
16
  import { getPreset } from "../presets.js";
17
- import { isSegmentEnabled, loadFooterSettings } from "../config.js";
17
+ import { isSegmentEnabled, isSegmentExplicitlyEnabled, loadFooterSettings } from "../config.js";
18
18
 
19
19
  /** Segment lookup by ID across all groups */
20
20
  interface SegmentLookup {
21
21
  get(id: string): FooterSegment | undefined;
22
+ /** All known segment IDs */
23
+ allIds(): string[];
22
24
  }
23
25
 
24
26
  /** Rendered segment with width info */
@@ -162,6 +164,19 @@ export class FooterRenderer {
162
164
  const primaryIds = [...presetDef.leftSegments, ...presetDef.rightSegments];
163
165
  const secondaryIds = [...presetDef.secondarySegments];
164
166
 
167
+ // Also include segments explicitly enabled by user that are NOT in the preset.
168
+ // This ensures toggling a segment "on" in the settings TUI makes it visible
169
+ // even when the active preset doesn't include it.
170
+ if (this.segmentLookup.allIds) {
171
+ for (const segId of this.segmentLookup.allIds()) {
172
+ if (primaryIds.includes(segId) || secondaryIds.includes(segId)) continue;
173
+ const groupId = this.getGroupForSegment(segId);
174
+ if (isSegmentExplicitlyEnabled(groupId, segId)) {
175
+ primaryIds.push(segId);
176
+ }
177
+ }
178
+ }
179
+
165
180
  // Render segments grouped by their zone
166
181
  const zones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
167
182
  left: [],
@@ -203,9 +218,11 @@ export class FooterRenderer {
203
218
 
204
219
  const sepDef = getSeparator(settings.separator);
205
220
  const sepWidth = visibleWidth(sepDef.left) + 2;
206
- const zoneSep = presetDef.zoneSeparator ?? settings.zoneSeparator ?? "\u2502";
207
- const zoneSepWidth = visibleWidth(zoneSep) + 2; // +2 for spaces around zone sep
208
- const dimZoneSep = `\x1b[2m${zoneSep}\x1b[0m`; // dimmed zone separator
221
+ const rawZoneSep = presetDef.zoneSeparator ?? settings.zoneSeparator ?? "\u2502";
222
+ const zoneSepHidden = !rawZoneSep || rawZoneSep === "none";
223
+ const zoneSep = zoneSepHidden ? "" : rawZoneSep;
224
+ const zoneSepWidth = zoneSepHidden ? 0 : visibleWidth(zoneSep) + 2; // +2 for spaces around zone sep
225
+ const dimZoneSep = zoneSepHidden ? "" : `\x1b[2m${zoneSep}\x1b[0m`; // dimmed zone separator
209
226
 
210
227
  // Calculate widths per zone
211
228
  const leftWidth = this.measureZoneWidth(zones.left, sepWidth);
@@ -213,15 +230,36 @@ export class FooterRenderer {
213
230
  const numZoneSeps = (leftWidth > 0 ? 1 : 0) + (rightWidth > 0 ? 1 : 0);
214
231
  const availableForCenter = width - leftWidth - rightWidth - numZoneSeps * zoneSepWidth - 2; // -2 for margins
215
232
 
233
+ // Progressive segment dropping: if left + right already exceed width,
234
+ // drop right-zone segments from the end until they fit.
235
+ const marginWidth = 2; // leading + trailing space
236
+ let adjustedRightWidth = rightWidth;
237
+ while (zones.right.length > 0 && leftWidth + adjustedRightWidth + marginWidth > width) {
238
+ const dropped = zones.right.pop()!;
239
+ adjustedRightWidth = this.measureZoneWidth(zones.right, sepWidth);
240
+ overflowZones.right.push(dropped);
241
+ }
242
+ // If left zone alone exceeds width, drop segments from the end until it fits.
243
+ let adjustedLeftWidth = leftWidth;
244
+ while (zones.left.length > 1 && adjustedLeftWidth + marginWidth > width) {
245
+ const dropped = zones.left.pop()!;
246
+ adjustedLeftWidth = this.measureZoneWidth(zones.left, sepWidth);
247
+ overflowZones.left.push(dropped);
248
+ }
249
+ // Recalculate available center after possible right-zone dropping
250
+ const adjLeftWidth = zones.left.length > 0 ? this.measureZoneWidth(zones.left, sepWidth) : 0;
251
+ const adjNumZoneSeps = (zones.left.length > 0 ? 1 : 0) + (zones.right.length > 0 ? 1 : 0);
252
+ const adjAvailableForCenter = width - adjLeftWidth - adjustedRightWidth - adjNumZoneSeps * zoneSepWidth - marginWidth;
253
+
216
254
  // Overflow check: if center doesn't fit, move excess to overflow
217
255
  const centerWidth = this.measureZoneWidth(zones.center, sepWidth);
218
- if (centerWidth > Math.max(0, availableForCenter)) {
256
+ if (centerWidth > Math.max(0, adjAvailableForCenter)) {
219
257
  // Move overflow center segments to secondary
220
258
  let fitWidth = 0;
221
259
  let cutoffIdx = 0;
222
260
  for (let i = 0; i < zones.center.length; i++) {
223
261
  const needed = zones.center[i].width + (i > 0 ? sepWidth : 0);
224
- if (fitWidth + needed <= Math.max(0, availableForCenter)) {
262
+ if (fitWidth + needed <= Math.max(0, adjAvailableForCenter)) {
225
263
  fitWidth += needed;
226
264
  cutoffIdx = i + 1;
227
265
  } else {
@@ -236,10 +274,11 @@ export class FooterRenderer {
236
274
  const topContent = this.buildZoneRow(zones, width, sepDef, dimZoneSep);
237
275
 
238
276
  // Build secondary row with overflow + preset secondary segments
239
- const allSecondary = [...overflowZones.center, ...secondaryRendered];
277
+ const allSecondary = [...overflowZones.left, ...overflowZones.center, ...overflowZones.right, ...secondaryRendered];
240
278
  const secondaryContent = this.buildContentFromParts(
241
279
  allSecondary.map(s => s.content),
242
280
  sepDef,
281
+ width,
243
282
  );
244
283
 
245
284
  this.lastLayoutResult = { topContent, secondaryContent };
@@ -334,7 +373,7 @@ export class FooterRenderer {
334
373
  }
335
374
 
336
375
  if (centerContent) {
337
- if (leftContent) result += ` ${dimZoneSep} `;
376
+ if (leftContent && dimZoneSep) result += ` ${dimZoneSep} `;
338
377
  result += centerContent;
339
378
  }
340
379
 
@@ -348,20 +387,22 @@ export class FooterRenderer {
348
387
  result += " ".repeat(gap);
349
388
  }
350
389
 
351
- if (centerContent || leftContent) {
352
- // Only add zone separator if there's content before it
353
- if (gap > visibleWidth(dimZoneSep) + 2) {
354
- // Place zone sep right before right content
355
- const sepPos = result.length - gap + Math.floor((gap - visibleWidth(dimZoneSep)) / 2);
356
- // Simpler: just put it at the boundary
357
- }
390
+ // If gap is negative, right zone doesn't fit — skip it to prevent overflow.
391
+ // truncateToWidth below is the safety net for any remaining excess.
392
+ if (gap > visibleWidth(dimZoneSep) + 2) {
393
+ // Enough room for zone separator aesthetic
358
394
  }
359
395
 
360
- result += rightContent;
396
+ // Only append right zone if it fits within terminal width
397
+ if (gap >= 0) {
398
+ result += rightContent;
399
+ }
361
400
  }
362
401
 
363
402
  result += " "; // trailing margin
364
- return result;
403
+
404
+ // Safety net: never exceed terminal width
405
+ return truncateToWidth(result, fullWidth);
365
406
  }
366
407
 
367
408
  /** Build content from parts array (raw strings) */
@@ -374,11 +415,17 @@ export class FooterRenderer {
374
415
 
375
416
  // ─── Helpers ─────────────────────────────────────────────────────────────
376
417
 
377
- private buildContentFromParts(parts: string[], sepDef: { left: string }): string {
418
+ private buildContentFromParts(parts: string[], sepDef: { left: string }, maxWidth?: number): string {
378
419
  if (parts.length === 0) return "";
379
420
  const sep = sepDef.left;
380
421
  const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
381
- return " " + parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `) + ANSI_RESET + " ";
422
+ const result = " " + parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `) + ANSI_RESET + " ";
423
+ // Safety net: never exceed maxWidth if provided
424
+ if (maxWidth != null && maxWidth > 0) {
425
+ return truncateToWidth(result, maxWidth);
426
+ }
427
+ // If no maxWidth, truncate to a reasonable default to prevent unbounded output
428
+ return truncateToWidth(result, 200);
382
429
  }
383
430
 
384
431
  /** Map a segment ID to its group ID */
@@ -388,7 +435,7 @@ export class FooterRenderer {
388
435
  if (coreIds.includes(segId)) return "core";
389
436
 
390
437
  // Compactor segments
391
- const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "indexed_docs", "sandbox_runs", "search_queries"];
438
+ const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "cocoindex_status", "sandbox_runs", "search_queries"];
392
439
  if (compactorIds.includes(segId)) return "compactor";
393
440
 
394
441
  // Memory segments
@@ -2,7 +2,7 @@
2
2
  * @pi-unipi/footer — Compactor segments
3
3
  *
4
4
  * Segment renderers for the compactor group: session_events, compactions,
5
- * tokens_saved, compression_ratio, indexed_docs, sandbox_runs, search_queries.
5
+ * tokens_saved, compression_ratio, cocoindex_status, sandbox_runs, search_queries.
6
6
  *
7
7
  * Data sourced from piContext.sessionManager (live session data).
8
8
  * Segments without a reliable data source are hidden (visible: false)
@@ -53,7 +53,7 @@ function renderSessionEventsSegment(ctx: FooterSegmentContext): RenderedSegment
53
53
  const count = events.length;
54
54
  if (count === 0) {
55
55
  if (isSegmentEnabled("compactor", "session_events")) {
56
- return { content: mutedPlaceholder("📈 CMP 0"), visible: true };
56
+ return { content: mutedPlaceholder(withIcon("sessionEvents", "0")), visible: true };
57
57
  }
58
58
  return hidden();
59
59
  }
@@ -74,7 +74,7 @@ function renderCompactionsSegment(ctx: FooterSegmentContext): RenderedSegment {
74
74
  }
75
75
  if (compactionCount === 0) {
76
76
  if (isSegmentEnabled("compactor", "compactions")) {
77
- return { content: mutedPlaceholder("🗜️ CMP 0"), visible: true };
77
+ return { content: mutedPlaceholder(withIcon("compactions", "0")), visible: true };
78
78
  }
79
79
  return hidden();
80
80
  }
@@ -84,15 +84,20 @@ function renderCompactionsSegment(ctx: FooterSegmentContext): RenderedSegment {
84
84
  }
85
85
 
86
86
  function renderTokensSavedSegment(ctx: FooterSegmentContext): RenderedSegment {
87
- // Sum tokens saved from compaction events if available
87
+ // Sum tokens saved from compaction entries.
88
+ // Pi's CompactionEntry has tokensBefore (total tokens before compaction).
89
+ // Compaction keeps ~10-15% of context, so tokens saved ≈ tokensBefore × 0.85.
88
90
  const events = getSessionEvents(ctx);
89
91
  let tokensSaved = 0;
90
92
  let hasCompaction = false;
91
93
  for (const e of events) {
92
94
  if (!e || typeof e !== "object") continue;
93
- if (e.type === "compaction" || e.type === "compacted") {
95
+ if (e.type === "compaction") {
94
96
  hasCompaction = true;
95
- tokensSaved += Number(e.tokensSaved ?? e.tokens_saved ?? 0);
97
+ const tokensBefore = Number(e.tokensBefore ?? 0);
98
+ // Estimate tokens kept at ~12% (compaction summary + recent messages)
99
+ const tokensAfter = Math.round(tokensBefore * 0.12);
100
+ tokensSaved += Math.max(0, tokensBefore - tokensAfter);
96
101
  }
97
102
  }
98
103
  if (!hasCompaction || tokensSaved === 0) return hidden();
@@ -102,37 +107,67 @@ function renderTokensSavedSegment(ctx: FooterSegmentContext): RenderedSegment {
102
107
  }
103
108
 
104
109
  function renderCompressionRatioSegment(ctx: FooterSegmentContext): RenderedSegment {
105
- // Check last compaction event for compression ratio
110
+ // Calculate compression ratio from Pi's CompactionEntry.tokensBefore.
111
+ // Compaction keeps ~12% of context, giving ~8:1 compression.
106
112
  const events = getSessionEvents(ctx);
107
- let lastRatio: number | undefined;
113
+ let totalBefore = 0;
114
+ let totalAfter = 0;
108
115
  for (const e of events) {
109
116
  if (!e || typeof e !== "object") continue;
110
- if (e.type === "compaction" || e.type === "compacted") {
111
- const ratio = e.compressionRatio ?? e.compression_ratio;
112
- if (ratio !== undefined && ratio !== null) {
113
- lastRatio = Number(ratio);
117
+ if (e.type === "compaction") {
118
+ const before = Number(e.tokensBefore ?? 0);
119
+ if (before > 0) {
120
+ totalBefore += before;
121
+ totalAfter += Math.round(before * 0.12);
114
122
  }
115
123
  }
116
124
  }
117
- if (lastRatio === undefined) return hidden();
125
+ if (totalBefore === 0 || totalAfter === 0) return hidden();
118
126
 
119
- const content = withIcon("compressionRatio", `${lastRatio.toFixed(1)}x`);
127
+ const ratio = totalBefore / totalAfter;
128
+ const content = withIcon("compressionRatio", `${ratio.toFixed(1)}x`);
120
129
  return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
121
130
  }
122
131
 
123
- function renderIndexedDocsSegment(_ctx: FooterSegmentContext): RenderedSegment {
124
- // No reliable data source for indexed docs count
132
+ function renderCocoindexStatusSegment(_ctx: FooterSegmentContext): RenderedSegment {
133
+ // CocoIndex status would need to query cocoindex bridge at render time.
134
+ // For now, hidden. Use /unipi:cocoindex-status for live status.
125
135
  return hidden();
126
136
  }
127
137
 
128
- function renderSandboxRunsSegment(_ctx: FooterSegmentContext): RenderedSegment {
129
- // No reliable data source for sandbox run count
130
- return hidden();
138
+ function renderSandboxRunsSegment(ctx: FooterSegmentContext): RenderedSegment {
139
+ // Count sandbox events from session manager branch
140
+ const events = getSessionEvents(ctx);
141
+ let sandboxCount = 0;
142
+ for (const e of events) {
143
+ if (!e || typeof e !== "object") continue;
144
+ // Count tool calls that are sandbox/execute tools
145
+ const name = String((e as any).name ?? "").toLowerCase();
146
+ if (name.includes("sandbox") || name.includes("ctx_execute") || name === "execute") {
147
+ sandboxCount++;
148
+ }
149
+ }
150
+ if (sandboxCount === 0) return hidden();
151
+
152
+ const content = withIcon("sandboxRuns", `${sandboxCount}`);
153
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
131
154
  }
132
155
 
133
- function renderSearchQueriesSegment(_ctx: FooterSegmentContext): RenderedSegment {
134
- // No reliable data source for search query count
135
- return hidden();
156
+ function renderSearchQueriesSegment(ctx: FooterSegmentContext): RenderedSegment {
157
+ // Count search events from session manager branch
158
+ const events = getSessionEvents(ctx);
159
+ let searchCount = 0;
160
+ for (const e of events) {
161
+ if (!e || typeof e !== "object") continue;
162
+ const name = String((e as any).name ?? "").toLowerCase();
163
+ if (name.includes("search") || name.includes("ctx_search")) {
164
+ searchCount++;
165
+ }
166
+ }
167
+ if (searchCount === 0) return hidden();
168
+
169
+ const content = withIcon("searchQueries", `${searchCount}`);
170
+ return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
136
171
  }
137
172
 
138
173
  export const COMPACTOR_SEGMENTS: FooterSegment[] = [
@@ -140,7 +175,7 @@ export const COMPACTOR_SEGMENTS: FooterSegment[] = [
140
175
  { id: "compactions", label: "Compactions", shortLabel: "CMP", description: "Number of context compactions", zone: "center", icon: "", render: renderCompactionsSegment, defaultShow: true },
141
176
  { id: "tokens_saved", label: "Tokens Saved", shortLabel: "SVD", description: "Tokens saved by compaction", zone: "center", icon: "", render: renderTokensSavedSegment, defaultShow: true },
142
177
  { id: "compression_ratio", label: "Compression Ratio", shortLabel: "RAT", description: "Last compaction compression ratio", zone: "center", icon: "", render: renderCompressionRatioSegment, defaultShow: false },
143
- { id: "indexed_docs", label: "Indexed Docs", shortLabel: "IDX", description: "Number of indexed documents", zone: "center", icon: "", render: renderIndexedDocsSegment, defaultShow: false },
178
+ { id: "cocoindex_status", label: "CocoIndex", shortLabel: "CIDX", description: "CocoIndex indexing status", zone: "center", icon: "", render: renderCocoindexStatusSegment, defaultShow: false },
144
179
  { id: "sandbox_runs", label: "Sandbox Runs", shortLabel: "SBX", description: "Number of sandbox code runs", zone: "center", icon: "", render: renderSandboxRunsSegment, defaultShow: false },
145
180
  { id: "search_queries", label: "Search Queries", shortLabel: "QRY", description: "Number of search queries", zone: "center", icon: "", render: renderSearchQueriesSegment, defaultShow: false },
146
181
  ];
@@ -176,7 +176,8 @@ function renderCostSegment(ctx: FooterSegmentContext): RenderedSegment {
176
176
  if (!stats.cost && !usingSubscription) return { content: "", visible: false };
177
177
 
178
178
  const costDisplay = usingSubscription ? "(sub)" : `$${stats.cost.toFixed(2)}`;
179
- return { content: color(ctx, "cost", costDisplay), visible: true };
179
+ const content = withIcon("cost", costDisplay);
180
+ return { content: color(ctx, "cost", content), visible: true };
180
181
  }
181
182
 
182
183
  function renderTokensSegment(variant: "total" | "in" | "out"): (ctx: FooterSegmentContext) => RenderedSegment {
@@ -22,7 +22,7 @@ function withIcon(segmentId: string, text: string): string {
22
22
  */
23
23
  function getKanboardData(): Record<string, unknown> | null {
24
24
  try {
25
- const registry = (globalThis as Record<string, unknown>).__unipi_kanboard_registry;
25
+ const registry = globalThis.__unipi_kanboard_registry;
26
26
  if (!registry || typeof registry !== "object") return null;
27
27
  return registry as Record<string, unknown>;
28
28
  } catch {
@@ -35,7 +35,7 @@ function renderDocsCountSegment(ctx: FooterSegmentContext): RenderedSegment {
35
35
  const value = kb?.docsCount;
36
36
  if (value === undefined || value === null) {
37
37
  if (isSegmentEnabled("kanboard", "docs_count")) {
38
- return { content: mutedPlaceholder("KB 0"), visible: true };
38
+ return { content: mutedPlaceholder(withIcon("docsCount", "0")), visible: true };
39
39
  }
40
40
  return { content: "", visible: false };
41
41
  }
@@ -48,7 +48,7 @@ function renderTasksDoneSegment(ctx: FooterSegmentContext): RenderedSegment {
48
48
  const value = kb?.tasksDone;
49
49
  if (value === undefined || value === null) {
50
50
  if (isSegmentEnabled("kanboard", "tasks_done")) {
51
- return { content: mutedPlaceholder("KB 0"), visible: true };
51
+ return { content: mutedPlaceholder(withIcon("tasksDone", "0")), visible: true };
52
52
  }
53
53
  return { content: "", visible: false };
54
54
  }
@@ -61,7 +61,7 @@ function renderTasksTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
61
61
  const value = kb?.tasksTotal;
62
62
  if (value === undefined || value === null) {
63
63
  if (isSegmentEnabled("kanboard", "tasks_total")) {
64
- return { content: mutedPlaceholder("KB 0"), visible: true };
64
+ return { content: mutedPlaceholder(withIcon("tasksTotal", "0")), visible: true };
65
65
  }
66
66
  return { content: "", visible: false };
67
67
  }
@@ -25,14 +25,6 @@ interface McpStats {
25
25
  toolsTotal?: number;
26
26
  }
27
27
 
28
- /** Shape of the global escape-hatch object */
29
- interface GlobalMcpStats extends McpStats {}
30
-
31
- declare global {
32
- // eslint-disable-next-line no-var
33
- var __unipi_mcp_stats: GlobalMcpStats | undefined;
34
- }
35
-
36
28
  function withIcon(segmentId: string, text: string): string {
37
29
  const icon = getIcon(segmentId);
38
30
  return icon ? `${icon} ${text}` : text;
@@ -67,7 +59,7 @@ function renderServersTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
67
59
  const stats = getMcpStats(ctx);
68
60
  if (!hasUsefulValue(stats.serversTotal)) {
69
61
  if (isSegmentEnabled("mcp", "servers_total")) {
70
- return { content: mutedPlaceholder("🖥️ MCP 0"), visible: true };
62
+ return { content: mutedPlaceholder(withIcon("serversTotal", "0")), visible: true };
71
63
  }
72
64
  return { content: "", visible: false };
73
65
  }
@@ -80,7 +72,7 @@ function renderServersActiveSegment(ctx: FooterSegmentContext): RenderedSegment
80
72
  if (!hasUsefulValue(stats.serversActive)) {
81
73
  if (isSegmentEnabled("mcp", "servers_active")) {
82
74
  const total = stats.serversTotal ?? 0;
83
- return { content: mutedPlaceholder(`🖥️ MCP ${total}/0`), visible: true };
75
+ return { content: mutedPlaceholder(withIcon("serversActive", `${total}/0`)), visible: true };
84
76
  }
85
77
  return { content: "", visible: false };
86
78
  }
@@ -92,7 +84,7 @@ function renderToolsTotalSegment(ctx: FooterSegmentContext): RenderedSegment {
92
84
  const stats = getMcpStats(ctx);
93
85
  if (!hasUsefulValue(stats.toolsTotal)) {
94
86
  if (isSegmentEnabled("mcp", "tools_total")) {
95
- return { content: mutedPlaceholder("🖥️ MCP 0"), visible: true };
87
+ return { content: mutedPlaceholder(withIcon("toolsTotal", "0")), visible: true };
96
88
  }
97
89
  return { content: "", visible: false };
98
90
  }
@@ -51,8 +51,7 @@ interface InfoRegistryLike {
51
51
  */
52
52
  function getInfoRegistryMemoryData(): InfoMemoryData | null {
53
53
  try {
54
- const g = globalThis as Record<string, unknown>;
55
- const registry = g.__unipi_info_registry;
54
+ const registry = globalThis.__unipi_info_registry;
56
55
  if (!registry || typeof registry !== "object") return null;
57
56
 
58
57
  // The info registry exposes getCachedData(groupId) synchronously
@@ -83,7 +82,7 @@ function renderProjectCountSegment(ctx: FooterSegmentContext): RenderedSegment {
83
82
  const counts = getMemoryCounts();
84
83
  if (counts.project === null) {
85
84
  if (isSegmentEnabled("memory", "project_count")) {
86
- return { content: mutedPlaceholder("🧠 MEM 0"), visible: true };
85
+ return { content: mutedPlaceholder(withIcon("projectCount", "0")), visible: true };
87
86
  }
88
87
  return { content: "", visible: false };
89
88
  }
@@ -26,7 +26,7 @@ function renderPlatformsEnabledSegment(ctx: FooterSegmentContext): RenderedSegme
26
26
  const platforms = data.platforms as string[] | undefined;
27
27
  if (!platforms || platforms.length === 0) {
28
28
  if (isSegmentEnabled("notify", "platforms_enabled")) {
29
- return { content: mutedPlaceholder("NTF OFF"), visible: true };
29
+ return { content: mutedPlaceholder(withIcon("platformsEnabled", "OFF")), visible: true };
30
30
  }
31
31
  return { content: "", visible: false };
32
32
  }
@@ -40,7 +40,7 @@ function renderLastSentSegment(ctx: FooterSegmentContext): RenderedSegment {
40
40
  const timestamp = data.timestamp as string | undefined;
41
41
  if (!timestamp) {
42
42
  if (isSegmentEnabled("notify", "last_sent")) {
43
- return { content: mutedPlaceholder("NTF 0"), visible: true };
43
+ return { content: mutedPlaceholder(withIcon("lastSent", "0")), visible: true };
44
44
  }
45
45
  return { content: "", visible: false };
46
46
  }
@@ -51,7 +51,8 @@ function renderActiveLoopsSegment(ctx: FooterSegmentContext): RenderedSegment {
51
51
  if (!active && !name && iteration === undefined) {
52
52
  // Show muted placeholder when enabled but no data
53
53
  if (isSegmentEnabled("ralph", "active_loops")) {
54
- return { content: mutedPlaceholder("🔁 RL OFF"), visible: true };
54
+ const ralphIcon = getIcon("activeLoops");
55
+ return { content: mutedPlaceholder(`${ralphIcon} OFF`), visible: true };
55
56
  }
56
57
  return { content: "", visible: false };
57
58
  }
@@ -81,9 +82,7 @@ function renderTotalIterationsSegment(ctx: FooterSegmentContext): RenderedSegmen
81
82
  const lastIteration = data.lastIteration as Record<string, unknown> | undefined;
82
83
  const iteration = data.iteration ?? lastIteration?.iteration;
83
84
  if (iteration === undefined || iteration === null) {
84
- if (isSegmentEnabled("ralph", "total_iterations")) {
85
- return { content: mutedPlaceholder("🔁 RL 0"), visible: true };
86
- }
85
+ // No data — hide to avoid duplicating active_loops placeholder
87
86
  return { content: "", visible: false };
88
87
  }
89
88
  const maxIterations = data.maxIterations;
@@ -101,15 +100,14 @@ function renderLoopStatusSegment(ctx: FooterSegmentContext): RenderedSegment {
101
100
  const status = data.status as string | undefined;
102
101
  const name = data.name as string | undefined;
103
102
  if (!status && !name) {
104
- if (isSegmentEnabled("ralph", "loop_status")) {
105
- return { content: mutedPlaceholder("🔁 RL OFF"), visible: true };
106
- }
103
+ // No data — hide to avoid duplicating active_loops placeholder
107
104
  return { content: "", visible: false };
108
105
  }
109
106
 
110
107
  const ralphIcon = getIcon("activeLoops");
111
108
  const dot = status === "active" ? GREEN_DOT : status === "completed" ? GREEN_DOT : RED_DOT;
112
- const statusIcon = status === "active" ? "" : status === "paused" ? "" : status === "completed" ? "✓" : "";
109
+ // Small geometric status indicators (▶ ⏸ ✓) work in all icon styles
110
+ const statusIcon = status === "active" ? "\u25B6" : status === "paused" ? "\u23F8" : status === "completed" ? "\u2713" : "";
113
111
  const display = name ? `${statusIcon} ${name}` : `${statusIcon}`;
114
112
 
115
113
  const active = status === "active" || status === "completed";
@@ -75,7 +75,8 @@ function renderCurrentCommandSegment(ctx: FooterSegmentContext): RenderedSegment
75
75
  return { content: applyColor("workflow", content, ctx.theme, ctx.colors), visible: true };
76
76
  }
77
77
 
78
- const statusPrefix = active ? "▶" : "✓";
78
+ // Small geometric status indicators work in all icon styles
79
+ const statusPrefix = active ? "\u25B6" : "\u2713";
79
80
  const semanticColor = getWorkflowSemanticColor(command);
80
81
  const content = `${workflowIcon} ${statusPrefix} ${command}`;
81
82
  return { content: applyColor(semanticColor, content, ctx.theme, ctx.colors), visible: true };
@@ -9,7 +9,7 @@
9
9
  * Uses pi-tui SettingsList for vim/arrow keybinding support.
10
10
  */
11
11
 
12
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
13
13
  import { SettingsList, type SettingItem, type SettingsListTheme } from "@mariozechner/pi-tui";
14
14
  import { loadFooterSettings, saveFooterSettings } from "../config.js";
15
15
  import { PRESET_NAMES } from "../presets.js";
@@ -30,6 +30,7 @@ const SECTION_LABELS: Record<Section, string> = {
30
30
 
31
31
  const SEPARATOR_STYLES: SeparatorStyle[] = ["powerline", "powerline-thin", "slash", "pipe", "dot", "ascii"];
32
32
  const ICON_STYLES: IconStyle[] = ["nerd", "emoji", "text"];
33
+ const ZONE_SEPARATOR_OPTIONS = ["│", "╎", "·", "─", "none"];
33
34
 
34
35
  // ─── Theme for SettingsList ────────────────────────────────────────────
35
36
 
@@ -65,9 +66,9 @@ function visibleWidth(text: string): number {
65
66
 
66
67
  // ─── Show the footer settings overlay ──────────────────────────────────
67
68
 
68
- export function showFooterSettings(ctx: any, groups: FooterGroup[], onSettingsChanged?: () => void): void {
69
- ctx.ui.custom(
70
- (tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
69
+ export function showFooterSettings(ctx: ExtensionCommandContext, groups: FooterGroup[], onSettingsChanged?: () => void): void {
70
+ ctx.ui.custom<void>(
71
+ (tui, _theme, _keybindings, done) => {
71
72
  const overlay = new FooterSettingsOverlay(groups, onSettingsChanged);
72
73
  overlay.onClose = () => done();
73
74
 
@@ -85,8 +86,7 @@ export function showFooterSettings(ctx: any, groups: FooterGroup[], onSettingsCh
85
86
  {
86
87
  overlay: true,
87
88
  overlayOptions: () => ({
88
- verticalAlign: "center",
89
- horizontalAlign: "center",
89
+ anchor: "center" as const,
90
90
  }),
91
91
  },
92
92
  ).catch(() => {
@@ -251,6 +251,13 @@ class FooterSettingsOverlay {
251
251
  currentValue: this.settings.iconStyle,
252
252
  values: ICON_STYLES,
253
253
  },
254
+ {
255
+ id: "zoneSeparator",
256
+ label: "Zone Separator",
257
+ description: "Divider between zones (left · center · right)",
258
+ currentValue: this.settings.zoneSeparator ?? "│",
259
+ values: ZONE_SEPARATOR_OPTIONS,
260
+ },
254
261
  {
255
262
  id: "showFullLabels",
256
263
  label: "Full Labels",
@@ -371,6 +378,9 @@ class FooterSettingsOverlay {
371
378
  this.settings.iconStyle = newValue as IconStyle;
372
379
  setIconStyle(newValue as IconStyle);
373
380
  break;
381
+ case "zoneSeparator":
382
+ this.settings.zoneSeparator = newValue;
383
+ break;
374
384
  case "showFullLabels":
375
385
  this.settings.showFullLabels = newValue === "on";
376
386
  // Sync with labels section
@@ -420,7 +430,16 @@ class FooterSettingsOverlay {
420
430
  // ─── Section navigation ────────────────────────────────────────────
421
431
 
422
432
  private getFocusedGroupId(): string | null {
423
- return this.selectedGroupId ?? this.groups[0]?.id ?? null;
433
+ if (this.selectedGroupId) return this.selectedGroupId;
434
+ // Access SettingsList internal state — it doesn't expose a getSelectedId() method
435
+ const list = this.groupList as unknown as {
436
+ selectedIndex: number;
437
+ items: SettingItem[];
438
+ filteredItems: SettingItem[];
439
+ searchEnabled: boolean;
440
+ };
441
+ const displayItems = list.searchEnabled ? list.filteredItems : list.items;
442
+ return displayItems[list.selectedIndex]?.id ?? null;
424
443
  }
425
444
 
426
445
  private enterSegmentsMode(groupId: string): void {