@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.
@@ -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,61 +84,65 @@ 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: "\uDB81\uDE5B", // 󰚩 custom model icon
84
- apiState: "\uF725", // 󱂛 api state icon
85
- toolCount: "\uF0AD", // tool count icon
86
- git: "\uF0E8", // git icon
87
- context: "\uF8D8", // context icon
88
- cost: "\uF155", // cost icon
89
- tokens: "\uF07B", // tokens icon
90
- tokensIn: "\uF07B", // tokens in icon
91
- tokensOut: "\uF07B", // tokens out icon
92
- session: "\uF550", // nf-md-identifier
93
- hostname: "\uF109", // nf-fa-laptop
94
- time: "\uF017", // nf-fa-clock_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", //
95
103
 
96
104
  // Compactor
97
- sessionEvents: "\uDBB1\uDECF", // 󰲏 session events icon
98
- compactions: "\uDBB1\uDECF", // 󰲏 compactions icon
99
- tokensSaved: "\uF155", // tokens saved icon
100
- compressionRatio:"\uDBB1\uDECF", // 󰲏 compression ratio icon
101
- indexedDocs: "\uDB81\uDE19", // 󰈙 indexed docs icon
102
- sandboxRuns: "\uF121", // sandbox runs icon
103
- 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", //
104
112
 
105
113
  // Memory
106
- projectCount: "\uDB81\uDED4", // 󰍚 memory icon
107
- totalCount: "\uEB9C", // total count icon
108
- consolidations: "\uDB81\uDED4", // 󰍚 consolidations icon
114
+ projectCount: "\uEE9C", //
115
+ totalCount: "\uEE9C", //
116
+ consolidations: "\uEE9C", //
109
117
 
110
118
  // MCP
111
- serversTotal: "\uF0F6", // servers total icon
112
- serversActive: "\uF058", // servers active icon
113
- toolsTotal: "\uF0AD", // tools total icon
114
- serversFailed: "\uF467", // servers failed icon
119
+ serversTotal: "\u{F05B7}", // 󰖷
120
+ serversActive: "\u{F05B7}", // 󰖷
121
+ toolsTotal: "\u{F05B7}", // 󰖷
122
+ serversFailed: "\u{F05B7}", // 󰖷
115
123
 
116
124
  // Ralph
117
- activeLoops: "\udb81\udf09", // 󰼉 ralph loop icon
118
- totalIterations: "\udb81\udf09", // 󰼉 ralph loop icon
119
- loopStatus: "\udb81\udf09", // 󰼉 ralph loop icon
125
+ activeLoops: "\u{F0709}", // 󰜉
126
+ totalIterations: "\u{F0709}", // 󰜉
127
+ loopStatus: "\u{F0709}", // 󰜉
120
128
 
121
129
  // Workflow
122
- currentCommand: "\uF0E8", // current command icon
123
- sandboxLevel: "\uDBB1\uDDFE", // 󰟾 sandbox level icon
124
- commandDuration: "\uDBB9\uDEAB", // 󱎫 command duration icon
130
+ currentCommand: "\uF124", //
131
+ sandboxLevel: "\u{F07FE}", // 󰟾
132
+ commandDuration: "\u{F13AB}", // 󱎫
125
133
 
126
134
  // Kanboard
127
- docsCount: "\uDB81\uDE19", // 󰈙 docs count icon
128
- tasksDone: "\uF0E8", // tasks done icon
129
- tasksTotal: "\uF0E8", // tasks total icon
130
- taskPct: "\uF0E8", // task pct icon
135
+ docsCount: "\u{F09EE}", // 󰧮
136
+ tasksDone: "\u{F1A9A}", // 󱪚
137
+ tasksTotal: "\uF4A0", //
138
+ taskPct: "\uF4A0", //
131
139
 
132
140
  // Notify
133
- platformsEnabled:"\uF0E0", // nf-fa-envelope
134
- lastSent: "\uF017", // nf-fa-clock_o
141
+ platformsEnabled:"\uEB9A", //
142
+ lastSent: "\u{F13AB}", // 󱎫
135
143
 
136
144
  // Extension status
137
- extensionStatuses:"\uDBB5\uDEAB", // 󱖫 extension statuses icon
145
+ extensionStatuses:"\u{F15AB}", // 󱖫
138
146
 
139
147
  separator: "\uE0B1", // nf-pl-left_soft_divider
140
148
  };
@@ -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,18 +7,20 @@
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";
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 */
@@ -143,8 +145,8 @@ export class FooterRenderer {
143
145
  }
144
146
 
145
147
  /**
146
- * Compute responsive layout for the given width.
147
- * Segments that don't fit in the top row overflow to the secondary row.
148
+ * Compute responsive zone-based layout for the given width.
149
+ * Segments are grouped by zone (left/center/right) and rendered with alignment.
148
150
  */
149
151
  computeLayout(width: number): { topContent: string; secondaryContent: string } {
150
152
  // Return cached layout if still valid
@@ -155,41 +157,58 @@ export class FooterRenderer {
155
157
 
156
158
  const presetDef = getPreset(this.presetName);
157
159
  const colors = presetDef.colors ?? getDefaultColors();
160
+ const settings = loadFooterSettings();
161
+ const labelMode = settings.showFullLabels ? "labeled" as const : "compact" as const;
162
+
163
+ // Collect all segment IDs from preset
164
+ const primaryIds = [...presetDef.leftSegments, ...presetDef.rightSegments];
165
+ const secondaryIds = [...presetDef.secondarySegments];
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
+ }
158
179
 
159
- // Render all segments
160
- const allSegmentIds = [
161
- ...presetDef.leftSegments,
162
- ...presetDef.rightSegments,
163
- ...presetDef.secondarySegments,
164
- ];
165
-
166
- const renderedSegments: RenderedSegmentWithWidth[] = [];
167
- for (const segId of allSegmentIds) {
168
- if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) continue;
180
+ // Render segments grouped by their zone
181
+ const zones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
182
+ left: [],
183
+ center: [],
184
+ right: [],
185
+ };
186
+ const overflowZones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
187
+ left: [],
188
+ center: [],
189
+ right: [],
190
+ };
169
191
 
192
+ // Render primary segments and group by zone
193
+ for (const segId of primaryIds) {
194
+ const rendered = this.renderSegment(segId, colors, width, labelMode);
195
+ if (!rendered) continue;
170
196
  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
- });
197
+ const zone = segment?.zone ?? "center";
198
+ zones[zone].push(rendered);
190
199
  }
191
200
 
192
- if (renderedSegments.length === 0) {
201
+ // Render secondary segments
202
+ const secondaryRendered: RenderedSegmentWithWidth[] = [];
203
+ for (const segId of secondaryIds) {
204
+ const rendered = this.renderSegment(segId, colors, width, labelMode);
205
+ if (!rendered) continue;
206
+ secondaryRendered.push(rendered);
207
+ }
208
+
209
+ // Check if we have any content
210
+ const totalSegments = zones.left.length + zones.center.length + zones.right.length;
211
+ if (totalSegments === 0 && secondaryRendered.length === 0) {
193
212
  this.lastLayoutResult = { topContent: "", secondaryContent: "" };
194
213
  this.lastLayoutWidth = width;
195
214
  this.lastLayoutTimestamp = now;
@@ -197,53 +216,70 @@ export class FooterRenderer {
197
216
  return this.lastLayoutResult;
198
217
  }
199
218
 
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);
219
+ const sepDef = getSeparator(settings.separator);
220
+ const sepWidth = visibleWidth(sepDef.left) + 2;
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
226
+
227
+ // Calculate widths per zone
228
+ const leftWidth = this.measureZoneWidth(zones.left, sepWidth);
229
+ const rightWidth = this.measureZoneWidth(zones.right, sepWidth);
230
+ const numZoneSeps = (leftWidth > 0 ? 1 : 0) + (rightWidth > 0 ? 1 : 0);
231
+ const availableForCenter = width - leftWidth - rightWidth - numZoneSeps * zoneSepWidth - 2; // -2 for margins
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);
209
241
  }
210
-
211
- // Compute responsive layout
212
- const sepDef = getSeparator(presetDef.separator);
213
- const sepWidth = visibleWidth(sepDef.left) + 2; // separator + spaces
214
-
215
- const baseOverhead = 2; // leading + trailing space
216
- let currentWidth = baseOverhead;
217
- let topParts: string[] = [];
218
- let overflowParts: RenderedSegmentWithWidth[] = [];
219
- let overflow = false;
220
-
221
- for (const seg of primarySegments) {
222
- const needed = seg.width + (topParts.length > 0 ? sepWidth : 0);
223
- if (!overflow && currentWidth + needed <= width) {
224
- topParts.push(seg.content);
225
- currentWidth += needed;
226
- } else {
227
- overflow = true;
228
- overflowParts.push(seg);
229
- }
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);
230
248
  }
231
-
232
- // Fit overflow into secondary row
233
- let secondaryWidth = baseOverhead;
234
- let secondaryParts: string[] = [];
235
- for (const seg of overflowParts) {
236
- const needed = seg.width + (secondaryParts.length > 0 ? sepWidth : 0);
237
- if (secondaryWidth + needed <= width) {
238
- secondaryParts.push(seg.content);
239
- secondaryWidth += needed;
240
- } else {
241
- break; // Stop at first non-fitting segment
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
+
254
+ // Overflow check: if center doesn't fit, move excess to overflow
255
+ const centerWidth = this.measureZoneWidth(zones.center, sepWidth);
256
+ if (centerWidth > Math.max(0, adjAvailableForCenter)) {
257
+ // Move overflow center segments to secondary
258
+ let fitWidth = 0;
259
+ let cutoffIdx = 0;
260
+ for (let i = 0; i < zones.center.length; i++) {
261
+ const needed = zones.center[i].width + (i > 0 ? sepWidth : 0);
262
+ if (fitWidth + needed <= Math.max(0, adjAvailableForCenter)) {
263
+ fitWidth += needed;
264
+ cutoffIdx = i + 1;
265
+ } else {
266
+ break;
267
+ }
242
268
  }
269
+ const overflow = zones.center.splice(cutoffIdx);
270
+ overflowZones.center.push(...overflow);
243
271
  }
244
272
 
245
- const topContent = this.buildContentFromParts(topParts, sepDef);
246
- const secondaryContent = this.buildContentFromParts(secondaryParts, sepDef);
273
+ // Build top row with zones
274
+ const topContent = this.buildZoneRow(zones, width, sepDef, dimZoneSep);
275
+
276
+ // Build secondary row with overflow + preset secondary segments
277
+ const allSecondary = [...overflowZones.left, ...overflowZones.center, ...overflowZones.right, ...secondaryRendered];
278
+ const secondaryContent = this.buildContentFromParts(
279
+ allSecondary.map(s => s.content),
280
+ sepDef,
281
+ width,
282
+ );
247
283
 
248
284
  this.lastLayoutResult = { topContent, secondaryContent };
249
285
  this.lastLayoutWidth = width;
@@ -253,23 +289,153 @@ export class FooterRenderer {
253
289
  return this.lastLayoutResult;
254
290
  }
255
291
 
292
+ /** Render a single segment by ID, returns null if not visible */
293
+ private renderSegment(
294
+ segId: string,
295
+ colors: ColorScheme,
296
+ fullWidth: number,
297
+ labelMode: "compact" | "labeled",
298
+ ): RenderedSegmentWithWidth | null {
299
+ if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) return null;
300
+
301
+ const segment = this.segmentLookup.get(segId);
302
+ if (!segment) return null;
303
+
304
+ const ctx: FooterSegmentContext = {
305
+ theme: this.getThemeLike(),
306
+ colors,
307
+ data: this.registry.getGroupData(this.getGroupForSegment(segId)),
308
+ width: fullWidth,
309
+ piContext: this.piContext,
310
+ footerData: this.footerData,
311
+ labelMode,
312
+ };
313
+
314
+ const rendered = segment.render(ctx);
315
+ if (!rendered.visible || !rendered.content) return null;
316
+
317
+ return {
318
+ content: rendered.content,
319
+ width: visibleWidth(rendered.content),
320
+ visible: true,
321
+ };
322
+ }
323
+
324
+ /** Measure total width of a zone's rendered segments */
325
+ private measureZoneWidth(segments: RenderedSegmentWithWidth[], sepWidth: number): number {
326
+ if (segments.length === 0) return 0;
327
+ let total = 0;
328
+ for (let i = 0; i < segments.length; i++) {
329
+ total += segments[i].width + (i > 0 ? sepWidth : 0);
330
+ }
331
+ return total;
332
+ }
333
+
334
+ /** Build a zone-based row string */
335
+ private buildZoneRow(
336
+ zones: Record<SegmentZone, RenderedSegmentWithWidth[]>,
337
+ fullWidth: number,
338
+ sepDef: { left: string },
339
+ dimZoneSep: string,
340
+ ): string {
341
+ const parts: string[] = [];
342
+
343
+ // Left zone
344
+ const leftContent = this.buildContentFromPartsRaw(
345
+ zones.left.map(s => s.content),
346
+ sepDef,
347
+ );
348
+
349
+ // Center zone
350
+ const centerContent = this.buildContentFromPartsRaw(
351
+ zones.center.map(s => s.content),
352
+ sepDef,
353
+ );
354
+
355
+ // Right zone
356
+ const rightContent = this.buildContentFromPartsRaw(
357
+ zones.right.map(s => s.content),
358
+ sepDef,
359
+ );
360
+
361
+ // Assemble zones with alignment
362
+ const leftWidth = zones.left.length > 0 ? this.measureZoneWidth(zones.left, visibleWidth(sepDef.left) + 2) : 0;
363
+ const rightWidth = zones.right.length > 0 ? this.measureZoneWidth(zones.right, visibleWidth(sepDef.left) + 2) : 0;
364
+
365
+ // Simple case: no zones → return empty
366
+ if (!leftContent && !centerContent && !rightContent) return "";
367
+
368
+ // Build with zone separators
369
+ let result = " "; // leading margin
370
+
371
+ if (leftContent) {
372
+ result += leftContent;
373
+ }
374
+
375
+ if (centerContent) {
376
+ if (leftContent && dimZoneSep) result += ` ${dimZoneSep} `;
377
+ result += centerContent;
378
+ }
379
+
380
+ if (rightContent) {
381
+ const currentLen = visibleWidth(result);
382
+ const rightStart = fullWidth - rightWidth - 1; // -1 for trailing margin
383
+ const gap = rightStart - currentLen;
384
+
385
+ if (gap > 0) {
386
+ // Pad to right-align the right zone
387
+ result += " ".repeat(gap);
388
+ }
389
+
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
394
+ }
395
+
396
+ // Only append right zone if it fits within terminal width
397
+ if (gap >= 0) {
398
+ result += rightContent;
399
+ }
400
+ }
401
+
402
+ result += " "; // trailing margin
403
+
404
+ // Safety net: never exceed terminal width
405
+ return truncateToWidth(result, fullWidth);
406
+ }
407
+
408
+ /** Build content from parts array (raw strings) */
409
+ private buildContentFromPartsRaw(parts: string[], sepDef: { left: string }): string {
410
+ if (parts.length === 0) return "";
411
+ const sep = sepDef.left;
412
+ const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
413
+ return parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `);
414
+ }
415
+
256
416
  // ─── Helpers ─────────────────────────────────────────────────────────────
257
417
 
258
- private buildContentFromParts(parts: string[], sepDef: { left: string }): string {
418
+ private buildContentFromParts(parts: string[], sepDef: { left: string }, maxWidth?: number): string {
259
419
  if (parts.length === 0) return "";
260
420
  const sep = sepDef.left;
261
421
  const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
262
- 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);
263
429
  }
264
430
 
265
431
  /** Map a segment ID to its group ID */
266
432
  private getGroupForSegment(segId: string): string {
267
433
  // Core segments
268
- const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time"];
434
+ 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
435
  if (coreIds.includes(segId)) return "core";
270
436
 
271
437
  // Compactor segments
272
- 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"];
273
439
  if (compactorIds.includes(segId)) return "compactor";
274
440
 
275
441
  // Memory segments