@pi-unipi/footer 0.1.3 → 0.1.4

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/index.ts CHANGED
@@ -27,6 +27,7 @@ import { STATUS_EXT_SEGMENTS } from "./segments/status-ext.js";
27
27
 
28
28
  import type { FooterGroup, FooterSegment } from "./types.js";
29
29
  import { rainbowBorder } from "./segments/core.js";
30
+ import { tpsTracker } from "./tps-tracker.js";
30
31
 
31
32
  /** All segment groups */
32
33
  const ALL_GROUPS: FooterGroup[] = [
@@ -124,6 +125,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
124
125
  state.refreshTimer = null;
125
126
  }
126
127
  state.tuiRef = null;
128
+ tpsTracker.reset();
127
129
  });
128
130
 
129
131
  // ─── Register commands ──────────────────────────────────────────────────
@@ -136,7 +138,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
136
138
  emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
137
139
  name: "@pi-unipi/footer",
138
140
  version: "0.1.0",
139
- commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`],
141
+ commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
140
142
  tools: [],
141
143
  });
142
144
  });
@@ -152,6 +154,28 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
152
154
  // Start periodic refresh for time-sensitive segments (e.g. clock)
153
155
  if (!state.refreshTimer) {
154
156
  state.refreshTimer = setInterval(() => {
157
+ // Feed TPS tracker with per-message data
158
+ try {
159
+ const piCtx = state.piContext as Record<string, unknown> | undefined;
160
+ if (piCtx?.sessionManager) {
161
+ const sm = (piCtx as any).sessionManager;
162
+ const events = sm?.getBranch?.() ?? [];
163
+ let msgIndex = 0;
164
+ for (const e of events) {
165
+ if (!e || typeof e !== "object") continue;
166
+ if (e.type !== "message") continue;
167
+ const m = e.message;
168
+ if (!m || m.role !== "assistant") continue;
169
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
170
+ const output = m.usage?.output ?? 0;
171
+ const hasStop = !!m.stopReason;
172
+ tpsTracker.onMessageUpdate(msgIndex, output, hasStop);
173
+ msgIndex++;
174
+ }
175
+ }
176
+ } catch {
177
+ // Silently ignore — TPS is best-effort
178
+ }
155
179
  state.renderer.resetLayoutCache();
156
180
  state.tuiRef?.requestRender();
157
181
  }, 1_000);
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", "indexed_docs",
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",
99
+ "hostname",
100
+ "tokens_in", "tokens_out",
92
101
  "compression_ratio", "indexed_docs",
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
 
@@ -27,6 +27,10 @@ export interface IconSet {
27
27
  session: string;
28
28
  hostname: string;
29
29
  time: string;
30
+ tps: string;
31
+ clock: string;
32
+ duration: string;
33
+ thinkingLevel: string;
30
34
 
31
35
  // Compactor segments
32
36
  sessionEvents: string;
@@ -92,6 +96,10 @@ export const NERD_ICONS: IconSet = {
92
96
  session: "\uF550", // nf-md-identifier
93
97
  hostname: "\uF109", // nf-fa-laptop
94
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
95
103
 
96
104
  // Compactor
97
105
  sessionEvents: "\uDBB1\uDECF", // 󰲏 session events icon
@@ -147,33 +155,38 @@ export const EMOJI_ICONS: IconSet = {
147
155
  model: "🤖",
148
156
  apiState: "🔄",
149
157
  toolCount: "🔧",
150
- git: "",
158
+ git: "🔀",
151
159
  context: "🗄️",
152
160
  cost: "💲",
153
- tokens: "",
154
- tokensIn: "",
155
- tokensOut: "",
156
- session: "#",
157
- hostname: "",
161
+ tokens: "📊",
162
+ tokensIn: "⬇️",
163
+ tokensOut: "⬆️",
164
+ session: "📋",
165
+ hostname: "🏠",
158
166
  time: "⏱",
159
167
 
168
+ tps: "⚡",
169
+ clock: "🕔",
170
+ duration: "⏱",
171
+ thinkingLevel: "💡",
172
+
160
173
  // Compactor
161
- sessionEvents: "",
162
- compactions: "",
174
+ sessionEvents: "📈",
175
+ compactions: "🗜️",
163
176
  tokensSaved: "💲",
164
- compressionRatio:"",
165
- indexedDocs: "",
166
- sandboxRuns: "",
167
- searchQueries: "",
177
+ compressionRatio:"📐",
178
+ indexedDocs: "📑",
179
+ sandboxRuns: "▶️",
180
+ searchQueries: "🔍",
168
181
 
169
182
  // Memory
170
183
  projectCount: "🧠",
171
184
  totalCount: "🧠",
172
- consolidations: "🧠",
185
+ consolidations: "🔄",
173
186
 
174
187
  // MCP
175
188
  serversTotal: "🖥️",
176
- serversActive: "",
189
+ serversActive: "🟢",
177
190
  toolsTotal: "🔧",
178
191
  serversFailed: "⚠️",
179
192
 
@@ -188,17 +201,17 @@ export const EMOJI_ICONS: IconSet = {
188
201
  commandDuration: "⏱",
189
202
 
190
203
  // Kanboard
191
- docsCount: "",
192
- tasksDone: "",
193
- tasksTotal: "",
194
- taskPct: "%",
204
+ docsCount: "📑",
205
+ tasksDone: "",
206
+ tasksTotal: "📋",
207
+ taskPct: "📊",
195
208
 
196
209
  // Notify
197
- platformsEnabled:"",
210
+ platformsEnabled:"🔔",
198
211
  lastSent: "⏱",
199
212
 
200
213
  // Extension status
201
- extensionStatuses:"",
214
+ extensionStatuses:"🧩",
202
215
 
203
216
  separator: "|",
204
217
  };
@@ -221,6 +234,11 @@ export const TEXT_ICONS: IconSet = {
221
234
  hostname: "HST",
222
235
  time: "TIM",
223
236
 
237
+ tps: "TPS",
238
+ clock: "CLK",
239
+ duration: "DUR",
240
+ thinkingLevel: "THK",
241
+
224
242
  // Compactor
225
243
  sessionEvents: "EVT",
226
244
  compactions: "CMP",
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Theme } from "@mariozechner/pi-coding-agent";
10
- import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, RenderedSegment } from "../types.js";
10
+ import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, RenderedSegment, SegmentZone } from "../types.js";
11
11
  import type { FooterRegistry } from "../registry/index.js";
12
12
  import { visibleWidth as piVisibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
13
13
  import { getSeparator, separatorVisibleWidth } from "./separators.js";
@@ -143,8 +143,8 @@ export class FooterRenderer {
143
143
  }
144
144
 
145
145
  /**
146
- * Compute responsive layout for the given width.
147
- * Segments that don't fit in the top row overflow to the secondary row.
146
+ * Compute responsive zone-based layout for the given width.
147
+ * Segments are grouped by zone (left/center/right) and rendered with alignment.
148
148
  */
149
149
  computeLayout(width: number): { topContent: string; secondaryContent: string } {
150
150
  // Return cached layout if still valid
@@ -155,41 +155,45 @@ export class FooterRenderer {
155
155
 
156
156
  const presetDef = getPreset(this.presetName);
157
157
  const colors = presetDef.colors ?? getDefaultColors();
158
+ const settings = loadFooterSettings();
159
+ const labelMode = settings.showFullLabels ? "labeled" as const : "compact" as const;
158
160
 
159
- // Render all segments
160
- const allSegmentIds = [
161
- ...presetDef.leftSegments,
162
- ...presetDef.rightSegments,
163
- ...presetDef.secondarySegments,
164
- ];
161
+ // Collect all segment IDs from preset
162
+ const primaryIds = [...presetDef.leftSegments, ...presetDef.rightSegments];
163
+ const secondaryIds = [...presetDef.secondarySegments];
165
164
 
166
- const renderedSegments: RenderedSegmentWithWidth[] = [];
167
- for (const segId of allSegmentIds) {
168
- if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) continue;
165
+ // Render segments grouped by their zone
166
+ const zones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
167
+ left: [],
168
+ center: [],
169
+ right: [],
170
+ };
171
+ const overflowZones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
172
+ left: [],
173
+ center: [],
174
+ right: [],
175
+ };
169
176
 
177
+ // Render primary segments and group by zone
178
+ for (const segId of primaryIds) {
179
+ const rendered = this.renderSegment(segId, colors, width, labelMode);
180
+ if (!rendered) continue;
170
181
  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
- });
182
+ const zone = segment?.zone ?? "center";
183
+ zones[zone].push(rendered);
184
+ }
185
+
186
+ // Render secondary segments
187
+ const secondaryRendered: RenderedSegmentWithWidth[] = [];
188
+ for (const segId of secondaryIds) {
189
+ const rendered = this.renderSegment(segId, colors, width, labelMode);
190
+ if (!rendered) continue;
191
+ secondaryRendered.push(rendered);
190
192
  }
191
193
 
192
- if (renderedSegments.length === 0) {
194
+ // Check if we have any content
195
+ const totalSegments = zones.left.length + zones.center.length + zones.right.length;
196
+ if (totalSegments === 0 && secondaryRendered.length === 0) {
193
197
  this.lastLayoutResult = { topContent: "", secondaryContent: "" };
194
198
  this.lastLayoutWidth = width;
195
199
  this.lastLayoutTimestamp = now;
@@ -197,53 +201,46 @@ export class FooterRenderer {
197
201
  return this.lastLayoutResult;
198
202
  }
199
203
 
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);
204
+ const sepDef = getSeparator(settings.separator);
205
+ 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
209
+
210
+ // Calculate widths per zone
211
+ const leftWidth = this.measureZoneWidth(zones.left, sepWidth);
212
+ const rightWidth = this.measureZoneWidth(zones.right, sepWidth);
213
+ const numZoneSeps = (leftWidth > 0 ? 1 : 0) + (rightWidth > 0 ? 1 : 0);
214
+ const availableForCenter = width - leftWidth - rightWidth - numZoneSeps * zoneSepWidth - 2; // -2 for margins
215
+
216
+ // Overflow check: if center doesn't fit, move excess to overflow
217
+ const centerWidth = this.measureZoneWidth(zones.center, sepWidth);
218
+ if (centerWidth > Math.max(0, availableForCenter)) {
219
+ // Move overflow center segments to secondary
220
+ let fitWidth = 0;
221
+ let cutoffIdx = 0;
222
+ for (let i = 0; i < zones.center.length; i++) {
223
+ const needed = zones.center[i].width + (i > 0 ? sepWidth : 0);
224
+ if (fitWidth + needed <= Math.max(0, availableForCenter)) {
225
+ fitWidth += needed;
226
+ cutoffIdx = i + 1;
227
+ } else {
228
+ break;
229
+ }
229
230
  }
231
+ const overflow = zones.center.splice(cutoffIdx);
232
+ overflowZones.center.push(...overflow);
230
233
  }
231
234
 
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
- }
235
+ // Build top row with zones
236
+ const topContent = this.buildZoneRow(zones, width, sepDef, dimZoneSep);
244
237
 
245
- const topContent = this.buildContentFromParts(topParts, sepDef);
246
- const secondaryContent = this.buildContentFromParts(secondaryParts, sepDef);
238
+ // Build secondary row with overflow + preset secondary segments
239
+ const allSecondary = [...overflowZones.center, ...secondaryRendered];
240
+ const secondaryContent = this.buildContentFromParts(
241
+ allSecondary.map(s => s.content),
242
+ sepDef,
243
+ );
247
244
 
248
245
  this.lastLayoutResult = { topContent, secondaryContent };
249
246
  this.lastLayoutWidth = width;
@@ -253,6 +250,128 @@ export class FooterRenderer {
253
250
  return this.lastLayoutResult;
254
251
  }
255
252
 
253
+ /** Render a single segment by ID, returns null if not visible */
254
+ private renderSegment(
255
+ segId: string,
256
+ colors: ColorScheme,
257
+ fullWidth: number,
258
+ labelMode: "compact" | "labeled",
259
+ ): RenderedSegmentWithWidth | null {
260
+ if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) return null;
261
+
262
+ const segment = this.segmentLookup.get(segId);
263
+ if (!segment) return null;
264
+
265
+ const ctx: FooterSegmentContext = {
266
+ theme: this.getThemeLike(),
267
+ colors,
268
+ data: this.registry.getGroupData(this.getGroupForSegment(segId)),
269
+ width: fullWidth,
270
+ piContext: this.piContext,
271
+ footerData: this.footerData,
272
+ labelMode,
273
+ };
274
+
275
+ const rendered = segment.render(ctx);
276
+ if (!rendered.visible || !rendered.content) return null;
277
+
278
+ return {
279
+ content: rendered.content,
280
+ width: visibleWidth(rendered.content),
281
+ visible: true,
282
+ };
283
+ }
284
+
285
+ /** Measure total width of a zone's rendered segments */
286
+ private measureZoneWidth(segments: RenderedSegmentWithWidth[], sepWidth: number): number {
287
+ if (segments.length === 0) return 0;
288
+ let total = 0;
289
+ for (let i = 0; i < segments.length; i++) {
290
+ total += segments[i].width + (i > 0 ? sepWidth : 0);
291
+ }
292
+ return total;
293
+ }
294
+
295
+ /** Build a zone-based row string */
296
+ private buildZoneRow(
297
+ zones: Record<SegmentZone, RenderedSegmentWithWidth[]>,
298
+ fullWidth: number,
299
+ sepDef: { left: string },
300
+ dimZoneSep: string,
301
+ ): string {
302
+ const parts: string[] = [];
303
+
304
+ // Left zone
305
+ const leftContent = this.buildContentFromPartsRaw(
306
+ zones.left.map(s => s.content),
307
+ sepDef,
308
+ );
309
+
310
+ // Center zone
311
+ const centerContent = this.buildContentFromPartsRaw(
312
+ zones.center.map(s => s.content),
313
+ sepDef,
314
+ );
315
+
316
+ // Right zone
317
+ const rightContent = this.buildContentFromPartsRaw(
318
+ zones.right.map(s => s.content),
319
+ sepDef,
320
+ );
321
+
322
+ // Assemble zones with alignment
323
+ const leftWidth = zones.left.length > 0 ? this.measureZoneWidth(zones.left, visibleWidth(sepDef.left) + 2) : 0;
324
+ const rightWidth = zones.right.length > 0 ? this.measureZoneWidth(zones.right, visibleWidth(sepDef.left) + 2) : 0;
325
+
326
+ // Simple case: no zones → return empty
327
+ if (!leftContent && !centerContent && !rightContent) return "";
328
+
329
+ // Build with zone separators
330
+ let result = " "; // leading margin
331
+
332
+ if (leftContent) {
333
+ result += leftContent;
334
+ }
335
+
336
+ if (centerContent) {
337
+ if (leftContent) result += ` ${dimZoneSep} `;
338
+ result += centerContent;
339
+ }
340
+
341
+ if (rightContent) {
342
+ const currentLen = visibleWidth(result);
343
+ const rightStart = fullWidth - rightWidth - 1; // -1 for trailing margin
344
+ const gap = rightStart - currentLen;
345
+
346
+ if (gap > 0) {
347
+ // Pad to right-align the right zone
348
+ result += " ".repeat(gap);
349
+ }
350
+
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
+ }
358
+ }
359
+
360
+ result += rightContent;
361
+ }
362
+
363
+ result += " "; // trailing margin
364
+ return result;
365
+ }
366
+
367
+ /** Build content from parts array (raw strings) */
368
+ private buildContentFromPartsRaw(parts: string[], sepDef: { left: string }): string {
369
+ if (parts.length === 0) return "";
370
+ const sep = sepDef.left;
371
+ const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
372
+ return parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `);
373
+ }
374
+
256
375
  // ─── Helpers ─────────────────────────────────────────────────────────────
257
376
 
258
377
  private buildContentFromParts(parts: string[], sepDef: { left: string }): string {
@@ -265,7 +384,7 @@ export class FooterRenderer {
265
384
  /** Map a segment ID to its group ID */
266
385
  private getGroupForSegment(segId: string): string {
267
386
  // Core segments
268
- const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time"];
387
+ 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"];
269
388
  if (coreIds.includes(segId)) return "core";
270
389
 
271
390
  // Compactor segments