@pi-unipi/footer 0.1.2 → 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.
@@ -16,8 +16,8 @@ import { detectNerdFontSupport } from "./separators.js";
16
16
  export interface IconSet {
17
17
  // Core segments
18
18
  model: string;
19
- thinking: string;
20
- path: string;
19
+ apiState: string;
20
+ toolCount: string;
21
21
  git: string;
22
22
  context: string;
23
23
  cost: string;
@@ -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;
@@ -80,38 +84,42 @@ export interface IconSet {
80
84
  /** Nerd Font glyphs — requires a Nerd Font installed in the terminal */
81
85
  export const NERD_ICONS: IconSet = {
82
86
  // Core
83
- model: "\uEC19", // nf-md-chip
84
- thinking: "\uE22C", // nf-oct-pi
85
- path: "\uF115", // nf-fa-folder_open
86
- git: "\uF126", // nf-fa-code_fork
87
- context: "\uE70F", // nf-dev-database
88
- cost: "\uF155", // nf-fa-dollar
89
- tokens: "\uE26B", // nf-seti-html
90
- tokensIn: "\uF090", // nf-fa-sign_in
91
- tokensOut: "\uF08B", // nf-fa-sign_out
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
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
- sessionEvents: "\uF0C0", // nf-fa-users
98
- compactions: "\uF1C0", // nf-fa-database
99
- tokensSaved: "\uF155", // nf-fa-dollar
100
- compressionRatio:"\uE70F", // nf-dev-database
101
- indexedDocs: "\uF02D", // nf-fa-book
102
- sandboxRuns: "\uF121", // nf-fa-terminal
103
- searchQueries: "\uF002", // nf-fa-search
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
104
112
 
105
113
  // Memory
106
- projectCount: "\uee9c", // memory icon
107
- totalCount: "\uee9c", // memory icon
108
- consolidations: "\uee9c", // memory icon
114
+ projectCount: "\uDB81\uDED4", // 󰍚 memory icon
115
+ totalCount: "\uEB9C", // total count icon
116
+ consolidations: "\uDB81\uDED4", // 󰍚 consolidations icon
109
117
 
110
118
  // MCP
111
- serversTotal: "\uF233", // nf-fa-server
112
- serversActive: "\uF058", // nf-fa-check_circle
113
- toolsTotal: "\uF0AD", // nf-fa-wrench
114
- serversFailed: "\uF071", // nf-fa-warning
119
+ serversTotal: "\uF0F6", // servers total icon
120
+ serversActive: "\uF058", // servers active icon
121
+ toolsTotal: "\uF0AD", // tools total icon
122
+ serversFailed: "\uF467", // servers failed icon
115
123
 
116
124
  // Ralph
117
125
  activeLoops: "\udb81\udf09", // 󰼉 ralph loop icon
@@ -119,22 +127,22 @@ export const NERD_ICONS: IconSet = {
119
127
  loopStatus: "\udb81\udf09", // 󰼉 ralph loop icon
120
128
 
121
129
  // Workflow
122
- currentCommand: "\uf52e", // workflow icon
123
- sandboxLevel: "\uf023", // nf-fa-lock
124
- commandDuration: "\uf017", // nf-fa-clock_o
130
+ currentCommand: "\uF0E8", // current command icon
131
+ sandboxLevel: "\uDBB1\uDDFE", // 󰟾 sandbox level icon
132
+ commandDuration: "\uDBB9\uDEAB", // 󱎫 command duration icon
125
133
 
126
134
  // Kanboard
127
- docsCount: "\uF15C", // nf-fa-file_text
128
- tasksDone: "\uF058", // nf-fa-check_circle
129
- tasksTotal: "\uF0AE", // nf-fa-tasks
130
- taskPct: "\uF200", // nf-fa-pie_chart
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
131
139
 
132
140
  // Notify
133
141
  platformsEnabled:"\uF0E0", // nf-fa-envelope
134
142
  lastSent: "\uF017", // nf-fa-clock_o
135
143
 
136
144
  // Extension status
137
- extensionStatuses:"\uF1E6", // nf-fa-plug
145
+ extensionStatuses:"\uDBB5\uDEAB", // 󱖫 extension statuses icon
138
146
 
139
147
  separator: "\uE0B1", // nf-pl-left_soft_divider
140
148
  };
@@ -144,61 +152,66 @@ export const NERD_ICONS: IconSet = {
144
152
  /** Unicode emoji / symbol icons — works on most modern terminals */
145
153
  export const EMOJI_ICONS: IconSet = {
146
154
  // Core
147
- model: "",
148
- thinking: "π",
149
- path: "",
150
- git: "",
151
- context: "",
152
- cost: "$",
153
- tokens: "",
154
- tokensIn: "",
155
- tokensOut: "",
156
- session: "#",
157
- hostname: "",
155
+ model: "🤖",
156
+ apiState: "🔄",
157
+ toolCount: "🔧",
158
+ git: "🔀",
159
+ context: "🗄️",
160
+ cost: "💲",
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: "",
163
- tokensSaved: "$",
164
- compressionRatio:"",
165
- indexedDocs: "",
166
- sandboxRuns: "",
167
- searchQueries: "",
174
+ sessionEvents: "📈",
175
+ compactions: "🗜️",
176
+ tokensSaved: "💲",
177
+ compressionRatio:"📐",
178
+ indexedDocs: "📑",
179
+ sandboxRuns: "▶️",
180
+ searchQueries: "🔍",
168
181
 
169
182
  // Memory
170
- projectCount: "\uee9c",
171
- totalCount: "\uee9c",
172
- consolidations: "\uee9c",
183
+ projectCount: "🧠",
184
+ totalCount: "🧠",
185
+ consolidations: "🔄",
173
186
 
174
187
  // MCP
175
- serversTotal: "srv",
176
- serversActive: "",
188
+ serversTotal: "🖥️",
189
+ serversActive: "🟢",
177
190
  toolsTotal: "🔧",
178
- serversFailed: "",
191
+ serversFailed: "⚠️",
179
192
 
180
193
  // Ralph
181
- activeLoops: "",
182
- totalIterations: "",
183
- loopStatus: "",
194
+ activeLoops: "🔁",
195
+ totalIterations: "🔁",
196
+ loopStatus: "🔁",
184
197
 
185
198
  // Workflow
186
- currentCommand: "",
187
- sandboxLevel: "",
199
+ currentCommand: "▶️",
200
+ sandboxLevel: "🔒",
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
  };
@@ -208,61 +221,66 @@ export const EMOJI_ICONS: IconSet = {
208
221
  /** Plain text labels — works everywhere, most compact */
209
222
  export const TEXT_ICONS: IconSet = {
210
223
  // Core
211
- model: "",
212
- thinking: "",
213
- path: "",
214
- git: "",
215
- context: "",
216
- cost: "",
217
- tokens: "",
218
- tokensIn: "",
219
- tokensOut: "",
220
- session: "",
221
- hostname: "",
222
- time: "",
224
+ model: "MDL",
225
+ apiState: "API",
226
+ toolCount: "TLS",
227
+ git: "GIT",
228
+ context: "CTX",
229
+ cost: "CST",
230
+ tokens: "TOK",
231
+ tokensIn: "TKI",
232
+ tokensOut: "TKO",
233
+ session: "SES",
234
+ hostname: "HST",
235
+ time: "TIM",
236
+
237
+ tps: "TPS",
238
+ clock: "CLK",
239
+ duration: "DUR",
240
+ thinkingLevel: "THK",
223
241
 
224
242
  // Compactor
225
- sessionEvents: "evt",
226
- compactions: "cmp",
227
- tokensSaved: "svd",
228
- compressionRatio:"rat",
229
- indexedDocs: "idx",
230
- sandboxRuns: "sbx",
231
- searchQueries: "qry",
243
+ sessionEvents: "EVT",
244
+ compactions: "CMP",
245
+ tokensSaved: "SVD",
246
+ compressionRatio:"RAT",
247
+ indexedDocs: "IDX",
248
+ sandboxRuns: "SBX",
249
+ searchQueries: "QRY",
232
250
 
233
251
  // Memory
234
- projectCount: "mem",
235
- totalCount: "mem",
236
- consolidations: "cns",
252
+ projectCount: "MEM",
253
+ totalCount: "MEM",
254
+ consolidations: "CNS",
237
255
 
238
256
  // MCP
239
- serversTotal: "srv",
240
- serversActive: "act",
241
- toolsTotal: "tls",
242
- serversFailed: "err",
257
+ serversTotal: "SRV",
258
+ serversActive: "ACT",
259
+ toolsTotal: "TLS",
260
+ serversFailed: "ERR",
243
261
 
244
262
  // Ralph
245
- activeLoops: "",
246
- totalIterations: "",
247
- loopStatus: "",
263
+ activeLoops: "LPS",
264
+ totalIterations: "ITR",
265
+ loopStatus: "STS",
248
266
 
249
267
  // Workflow
250
- currentCommand: "",
251
- sandboxLevel: "sbx",
252
- commandDuration: "dur",
268
+ currentCommand: "CMD",
269
+ sandboxLevel: "SBX",
270
+ commandDuration: "DUR",
253
271
 
254
272
  // Kanboard
255
- docsCount: "doc",
256
- tasksDone: "",
257
- tasksTotal: "tsk",
258
- taskPct: "pct",
273
+ docsCount: "DOC",
274
+ tasksDone: "DNE",
275
+ tasksTotal: "TSK",
276
+ taskPct: "PCT",
259
277
 
260
278
  // Notify
261
- platformsEnabled:"ntf",
262
- lastSent: "lst",
279
+ platformsEnabled:"NTF",
280
+ lastSent: "LST",
263
281
 
264
282
  // Extension status
265
- extensionStatuses:"ext",
283
+ extensionStatuses:"EXT",
266
284
 
267
285
  separator: "|",
268
286
  };
@@ -7,8 +7,9 @@
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
+ import { visibleWidth as piVisibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
12
13
  import { getSeparator, separatorVisibleWidth } from "./separators.js";
13
14
  import { getDefaultColors } from "./theme.js";
14
15
  import { setIconStyle } from "./icons.js";
@@ -29,10 +30,9 @@ interface RenderedSegmentWithWidth {
29
30
 
30
31
  // ─── ANSI helpers ───────────────────────────────────────────────────────────
31
32
 
32
- /** Strip ANSI escape codes and measure visible width */
33
+ /** ANSI-aware visible width using pi-tui */
33
34
  function visibleWidth(text: string): number {
34
- const stripped = text.replace(/\x1b\[[0-9;]*m/g, "");
35
- return stripped.length;
35
+ return piVisibleWidth(text);
36
36
  }
37
37
 
38
38
  const ANSI_RESET = "\x1b[0m";
@@ -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", "thinking", "path", "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