@underactive/pi-topping-statusline 0.2.1 → 0.2.2

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/layout.ts CHANGED
@@ -12,13 +12,18 @@
12
12
  * column and row in the box, so a caller can paint it as part of a
13
13
  * continuous border gradient (the rainbow effect).
14
14
  *
15
- * `options.leftFade` (0..1) blends every left segment after a leading `pi`
16
- * symbol toward the bar background, which the working-indicator transition
17
- * uses to cross-fade the group without moving the symbol or its chevron.
15
+ * `options.leftFade` (0..1) blends transient left segments toward the bar
16
+ * background while keeping the Pi symbol and model details solid, which the
17
+ * working-indicator transition uses without hiding stable model information.
18
+ * `options.workingReveal` (0..1) slides the `working` status out from behind
19
+ * the segment before it, trailing separator first, pushing the segments after
20
+ * it right. Overflow is resolved for the fully revealed status, so no segment
21
+ * appears or drops mid-slide and the last frame matches the settled bar.
18
22
  */
19
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
23
+ import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
24
  import { renderSegment } from "./segments.js";
21
25
  import { getSeparator } from "./separators.js";
26
+ import { STATUS_STABLE_SEGMENTS } from "./settings.js";
22
27
  import { theme } from "./theme.js";
23
28
  import type { EffectiveStatusLineSettings, SegmentContext, StatusLineSegmentId } from "./types.js";
24
29
 
@@ -65,7 +70,13 @@ export function buildStatusLine(
65
70
  gapBorderColor: (str: string, startCol: number, row: number) => string,
66
71
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
67
72
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
68
- options: { leftFade?: number; workingEllipsis?: string } = {},
73
+ options: {
74
+ leftFade?: number;
75
+ workingReveal?: number;
76
+ workingEllipsis?: string;
77
+ /** Called once overflow trimming settles, with the segment ids that actually made it into each rendered group. */
78
+ onFit?: (fit: { left: readonly StatusLineSegmentId[]; right: readonly StatusLineSegmentId[] }) => void;
79
+ } = {},
69
80
  ): string {
70
81
  const separatorDef = getSeparator(settings.separator);
71
82
 
@@ -73,6 +84,8 @@ export function buildStatusLine(
73
84
  const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
74
85
  const fgAnsi = theme.getFgAnsi("text");
75
86
  const sepAnsi = theme.getFgAnsi("statusLineSep");
87
+ const leftSepText = ` ${sepAnsi}${separatorDef.left}${fgAnsi} `;
88
+ const rightSepText = ` ${sepAnsi}${separatorDef.right}${fgAnsi} `;
76
89
 
77
90
  const left: string[] = [];
78
91
  const leftSegIds: StatusLineSegmentId[] = [];
@@ -85,10 +98,12 @@ export function buildStatusLine(
85
98
  }
86
99
 
87
100
  const right: string[] = [];
101
+ const rightSegIds: StatusLineSegmentId[] = [];
88
102
  for (const segId of segmentGroups.right) {
89
103
  const rendered = renderSegment(segId, ctx);
90
104
  if (rendered.visible && rendered.content) {
91
105
  right.push(rendered.content);
106
+ rightSegIds.push(segId);
92
107
  }
93
108
  }
94
109
 
@@ -105,7 +120,7 @@ export function buildStatusLine(
105
120
  const groupWidth = (parts: string[], sepWidth: number): number => {
106
121
  if (parts.length === 0) return 0;
107
122
  const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
108
- const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
123
+ const sepTotal = (parts.length - 1) * (sepWidth + 2);
109
124
  return partsWidth + sepTotal + 2 + capWidth;
110
125
  };
111
126
 
@@ -118,15 +133,27 @@ export function buildStatusLine(
118
133
  if (width > 0) {
119
134
  while (totalWidth() > width && right.length > 0) {
120
135
  right.pop();
136
+ rightSegIds.pop();
121
137
  rightWidth = rightGroupWidth(right);
122
138
  }
123
139
  // The working status absorbs overflow first so the Pi symbol beside it
124
- // survives on narrow terminals.
140
+ // survives on narrow terminals. Segments trailing it are dropped first so
141
+ // a static segment (e.g. model) never outlives the live status indicator.
125
142
  const workingIdx = leftSegIds.indexOf("working");
143
+ while (
144
+ workingIdx >= 0 &&
145
+ workingIdx < left.length - 1 &&
146
+ visibleWidth(left[workingIdx]) - (totalWidth() - width) < 1
147
+ ) {
148
+ left.pop();
149
+ leftSegIds.pop();
150
+ leftWidth = leftGroupWidth(left);
151
+ }
126
152
  if (workingIdx >= 0 && totalWidth() > width) {
127
153
  const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
128
154
  if (available >= 1) {
129
- left[workingIdx] = truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "");
155
+ left[workingIdx] =
156
+ truncateToWidth(left[workingIdx], available, options.workingEllipsis ?? "") + bgAnsi + fgAnsi;
130
157
  } else {
131
158
  left.splice(workingIdx, 1);
132
159
  leftSegIds.splice(workingIdx, 1);
@@ -158,25 +185,49 @@ export function buildStatusLine(
158
185
  }
159
186
  }
160
187
 
161
- const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
162
- if (parts.length === 0) return "";
163
- const sep = direction === "left" ? separatorDef.left : separatorDef.right;
164
- const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
165
- const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
166
-
167
- let body: string;
168
- const fade = options.leftFade;
169
- if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
170
- // The faded tail opens with an explicit default-fg reset so plain text
171
- // inside it is recolored too, not just segments that set their own color.
172
- const head = parts.slice(0, fadeFrom).join(sepText);
173
- const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
174
- body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
188
+ // Trimming above only drops or truncates segments, never adds them back, so
189
+ // the surviving ids are settled here regardless of width.
190
+ options.onFit?.({ left: leftSegIds, right: rightSegIds });
191
+
192
+ const fade = options.leftFade;
193
+ if (fade !== undefined && fade < 1) {
194
+ for (const [i, id] of leftSegIds.entries()) {
195
+ if (STATUS_STABLE_SEGMENTS.has(id)) continue;
196
+ // Reset first so plain text is recolored too, not just segments that
197
+ // set their own foreground color.
198
+ left[i] = theme.fadeAnsi(`\x1b[39m${left[i]}`, fade);
199
+ }
200
+ }
201
+
202
+ const reveal = options.workingReveal;
203
+ const slideIdx = leftSegIds.indexOf("working");
204
+ if (reveal !== undefined && reveal < 1 && slideIdx >= 0) {
205
+ // Only the status's tail has emerged. Ahead of another segment, the tail
206
+ // includes the separator between them and is joined onto that segment,
207
+ // which then moves right one cell per revealed cell.
208
+ const pushes = slideIdx < left.length - 1;
209
+ const unit = pushes ? left[slideIdx] + leftSepText : left[slideIdx];
210
+ const unitWidth = visibleWidth(unit);
211
+ const shown = Math.round(unitWidth * reveal);
212
+ if (shown <= 0) {
213
+ left.splice(slideIdx, 1);
214
+ leftSegIds.splice(slideIdx, 1);
215
+ } else if (pushes) {
216
+ left.splice(slideIdx, 2, sliceByColumn(unit, unitWidth - shown, shown) + left[slideIdx + 1]);
217
+ leftSegIds.splice(slideIdx, 1);
175
218
  } else {
176
- body = parts.join(sepText);
219
+ left[slideIdx] = sliceByColumn(unit, unitWidth - shown, shown);
177
220
  }
221
+ leftWidth = leftGroupWidth(left);
222
+ }
223
+
224
+ const renderGroup = (parts: string[], direction: "left" | "right"): string => {
225
+ if (parts.length === 0) return "";
226
+ const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
227
+ const sepText = direction === "left" ? leftSepText : rightSepText;
228
+
178
229
  let content = bgAnsi + fgAnsi;
179
- content += ` ${body} `;
230
+ content += ` ${parts.join(sepText)} `;
180
231
  content += "\x1b[0m";
181
232
 
182
233
  if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
@@ -184,7 +235,7 @@ export function buildStatusLine(
184
235
  return content;
185
236
  };
186
237
 
187
- const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
238
+ const leftGroup = renderGroup(left, "left");
188
239
  const rightGroup = renderGroup(right, "right");
189
240
  if (!leftGroup && !rightGroup) return "";
190
241
 
@@ -198,7 +249,5 @@ export function buildStatusLine(
198
249
  barOrigin.col + leftWidth,
199
250
  barOrigin.row,
200
251
  );
201
- if (!leftGroup) return gapFill + rightGroup;
202
- if (!rightGroup) return leftGroup + gapFill;
203
252
  return leftGroup + gapFill + rightGroup;
204
253
  }
package/src/menu.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * ║ ╭── π ⬢ model ───────────────────── session ──╮ ║
17
17
  * ║ ║
18
18
  * ╟─ Global ─────────────────────────────────────────╢
19
- * ║ ▸ [■] Transparent Segments ON ║
19
+ * ║ > [■] Transparent Segments ON ║
20
20
  * ║ Separator ‹ powerline-thin › ║
21
21
  * ║ ║
22
22
  * ╟──────────────────────────────────────────────────╢
@@ -118,11 +118,6 @@ const PLAIN_ROW_PREFIX_LEN = 5;
118
118
  const CHECKBOX_ROW_PREFIX_LEN = 8;
119
119
 
120
120
  interface FlatItem {
121
- id: string;
122
- label: string;
123
- cycleValues?: readonly string[];
124
- cycleEnabledBy?: string;
125
- cycleDisabledValue?: string;
126
121
  item: MenuItem;
127
122
  sectionIndex: number;
128
123
  }
@@ -131,15 +126,7 @@ function flattenSections(sections: MenuSection[]): FlatItem[] {
131
126
  const flat: FlatItem[] = [];
132
127
  for (const [sectionIndex, section] of sections.entries()) {
133
128
  for (const item of section.items) {
134
- flat.push({
135
- id: item.id,
136
- label: item.label,
137
- cycleValues: item.cycleValues,
138
- cycleEnabledBy: item.cycleEnabledBy,
139
- cycleDisabledValue: item.cycleDisabledValue,
140
- item,
141
- sectionIndex,
142
- });
129
+ flat.push({ item, sectionIndex });
143
130
  }
144
131
  }
145
132
  return flat;
@@ -260,26 +247,26 @@ export class MenuComponent implements Component {
260
247
  [Key.space]: () => {
261
248
  const item = this.flat[this.cursor]!;
262
249
  if (item.item.action) {
263
- this.runAction(item.id);
250
+ this.runAction(item.item.id);
264
251
  return;
265
252
  }
266
253
  if (item.item.text) {
267
254
  this.beginEditing(item);
268
255
  return;
269
256
  }
270
- if (item.cycleValues && item.cycleEnabledBy) {
271
- this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
272
- if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) {
273
- this.values[item.id] = item.cycleDisabledValue;
257
+ if (item.item.cycleValues && item.item.cycleEnabledBy) {
258
+ this.values[item.item.cycleEnabledBy] = !this.values[item.item.cycleEnabledBy] as boolean;
259
+ if (!this.values[item.item.cycleEnabledBy] && item.item.cycleDisabledValue !== undefined) {
260
+ this.values[item.item.id] = item.item.cycleDisabledValue;
274
261
  }
275
- } else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean;
262
+ } else if (!item.item.cycleValues) this.values[item.item.id] = !this.values[item.item.id] as boolean;
276
263
  this.invalidate();
277
264
  },
278
265
  // Enter opens an editor or fires an action on those rows, so applying
279
266
  // the whole menu stays available from any other row.
280
267
  [Key.enter]: () => {
281
268
  const item = this.flat[this.cursor]!;
282
- if (item.item.action) this.runAction(item.id);
269
+ if (item.item.action) this.runAction(item.item.id);
283
270
  else if (item.item.text) this.beginEditing(item);
284
271
  else this.done({ applied: true, values: { ...this.values } });
285
272
  },
@@ -297,7 +284,7 @@ export class MenuComponent implements Component {
297
284
  }
298
285
 
299
286
  private beginEditing(item: FlatItem): void {
300
- this.editing = { id: item.id, buffer: String(this.values[item.id] ?? "") };
287
+ this.editing = { id: item.item.id, buffer: String(this.values[item.item.id] ?? "") };
301
288
  this.invalidate();
302
289
  }
303
290
 
@@ -373,10 +360,10 @@ export class MenuComponent implements Component {
373
360
 
374
361
  private cycleCurrentValue(delta: number): void {
375
362
  const item = this.flat[this.cursor]!;
376
- if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
377
- const current = item.cycleValues.indexOf(this.values[item.id] as string);
378
- const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
379
- this.values[item.id] = item.cycleValues[index]!;
363
+ if (!item.item.cycleValues?.length || (item.item.cycleEnabledBy && !this.values[item.item.cycleEnabledBy])) return;
364
+ const current = item.item.cycleValues.indexOf(this.values[item.item.id] as string);
365
+ const index = (current + delta + item.item.cycleValues.length) % item.item.cycleValues.length;
366
+ this.values[item.item.id] = item.item.cycleValues[index]!;
380
367
  this.invalidate();
381
368
  }
382
369
 
@@ -386,7 +373,7 @@ export class MenuComponent implements Component {
386
373
  const result = this.previewFn(
387
374
  this.values,
388
375
  this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
389
- this.flat[this.cursor]?.id,
376
+ this.flat[this.cursor]?.item.id,
390
377
  innerWidth,
391
378
  );
392
379
  if (Array.isArray(result)) {
@@ -500,8 +487,8 @@ export class MenuComponent implements Component {
500
487
  }
501
488
 
502
489
  private availableRows(): number | undefined {
503
- const rows = (this.tui as (TUI & { terminal?: { rows?: number } }) | undefined)?.terminal?.rows;
504
- return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : undefined;
490
+ const rows = this.tui?.terminal.rows;
491
+ return rows !== undefined && rows > 0 ? rows : undefined;
505
492
  }
506
493
 
507
494
  private buildLines(maxWidth: number, maxRows?: number): string[] {
@@ -597,15 +584,17 @@ export class MenuComponent implements Component {
597
584
  private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
598
585
  const th = this.theme;
599
586
  const value = this.values[item.id]!;
600
- const marker = selected ? "\u25b8" : " ";
587
+ const marker = selected ? ">" : " ";
601
588
  const markerColored = selected ? th.fg("accent", marker) : marker;
589
+ const rowBackground = (content: string) =>
590
+ selected ? th.bg("selectedBg", content.replaceAll("\x1b[0m", `\x1b[0m${th.getBgAnsi("selectedBg")}`)) : content;
602
591
 
603
592
  if (item.action) {
604
593
  const label = truncateToWidth(item.label, Math.max(0, innerWidth - 5));
605
594
  const leftPlain = ` ${marker} ${label}`;
606
595
  const pad = Math.max(0, innerWidth - visibleWidth(leftPlain));
607
596
  const content = ` ${markerColored} ${th.fg(selected ? "accent" : "muted", label)}${" ".repeat(pad)}`;
608
- return this.wrap("\u2551", content, "\u2551");
597
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
609
598
  }
610
599
 
611
600
  if (item.text) {
@@ -626,7 +615,7 @@ export class MenuComponent implements Component {
626
615
  const leftPlain = ` ${marker} ${label} ${fieldText}${caret}`;
627
616
  const pad = Math.max(0, innerWidth - visibleWidth(leftPlain) - 1);
628
617
  const content = ` ${markerColored} ${th.fg("dim", label)} ${fieldStyled}${editing ? th.fg("accent", caret) : ""}${" ".repeat(pad)} `;
629
- return this.wrap("\u2551", content, "\u2551");
618
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
630
619
  }
631
620
 
632
621
  if (item.cycleValues) {
@@ -638,7 +627,7 @@ export class MenuComponent implements Component {
638
627
  const leftPlain = ` ${marker} ${label}`;
639
628
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
640
629
  const content = ` ${markerColored} ${th.fg("text", label)}${" ".repeat(gap)}${th.fg("accent", stateWord)} `;
641
- return this.wrap("\u2551", content, "\u2551");
630
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
642
631
  }
643
632
  const enabled = this.values[item.cycleEnabledBy] as boolean;
644
633
  const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
@@ -646,7 +635,7 @@ export class MenuComponent implements Component {
646
635
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
647
636
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
648
637
  const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
649
- return this.wrap("\u2551", content, "\u2551");
638
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
650
639
  }
651
640
 
652
641
  const enabled = value as boolean;
@@ -658,7 +647,7 @@ export class MenuComponent implements Component {
658
647
  const leftPlain = ` ${marker} [${box}] ${label}`;
659
648
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
660
649
  const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
661
- return this.wrap("\u2551", content, "\u2551");
650
+ return this.wrap("\u2551", rowBackground(content), "\u2551");
662
651
  }
663
652
  }
664
653
 
package/src/segments.ts CHANGED
@@ -14,6 +14,7 @@ import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
14
14
  import {
15
15
  clampPathLength,
16
16
  feedKey,
17
+ formatNumber,
17
18
  getSessionAccentHex,
18
19
  sanitizeLabel,
19
20
  sanitizeStatusText,
@@ -21,6 +22,7 @@ import {
21
22
  withIcon,
22
23
  } from "./utils.js";
23
24
  import type {
25
+ CompactionProgressView,
24
26
  FeedFormat,
25
27
  RenderedSegment,
26
28
  SegmentContext,
@@ -344,12 +346,62 @@ const contextGraphSegment: StatusLineSegment = {
344
346
  labelColor: theme.getFgAnsi("dim"),
345
347
  showBar,
346
348
  showStats,
349
+ estimated: ctx.contextEstimated === true,
347
350
  }),
348
351
  visible: true,
349
352
  };
350
353
  },
351
354
  };
352
355
 
356
+ const clampPercent = (percent: number): number => Math.round(Math.min(100, Math.max(0, percent)));
357
+
358
+ /** "57% summarized · 16.3s": how much of the context has been summarized so far, and the time taken. */
359
+ export function formatCompactionInfo(view: CompactionProgressView): string {
360
+ const elapsed = `${(Math.max(0, view.elapsedMs) / 1000).toFixed(1)}s`;
361
+ return `${clampPercent(view.percent)}% summarized${theme.sep.dot}${elapsed}`;
362
+ }
363
+
364
+ /**
365
+ * "86K/131K (66%)": the tokens being compacted over the window, and as a share
366
+ * of it, in the context label's number style. The share is omitted when the
367
+ * window is unknown.
368
+ */
369
+ export function formatCompactionTokens(view: CompactionProgressView): string {
370
+ const before = formatNumber(Math.max(0, view.tokensBefore));
371
+ if (view.contextWindow <= 0) return `${before}/?`;
372
+ const usage = clampPercent((view.tokensBefore / view.contextWindow) * 100);
373
+ return `${before}/${formatNumber(view.contextWindow)} (${usage}%)`;
374
+ }
375
+
376
+ /** pi-topping-compact's figures, standing in for pi's stats while its progress is hosted. */
377
+ const compactionInfoSegment: StatusLineSegment = {
378
+ id: "compaction_info",
379
+ render(ctx) {
380
+ const view = ctx.compaction;
381
+ if (!view) return INVISIBLE;
382
+ return { content: theme.fg("dim", formatCompactionInfo(view)), visible: true };
383
+ },
384
+ };
385
+
386
+ /**
387
+ * pi-topping-compact's bar, standing in for the context graph while its progress
388
+ * is hosted, under the graph's own bar and stats toggles. The bar arrives
389
+ * pre-rendered with fg-only resets, so it is passed through verbatim.
390
+ */
391
+ const compactionGraphSegment: StatusLineSegment = {
392
+ id: "compaction_graph",
393
+ render(ctx) {
394
+ const view = ctx.compaction;
395
+ if (!view) return INVISIBLE;
396
+ const { showBar, showStats } = ctx.options.context;
397
+ const parts: string[] = [];
398
+ if (showBar) parts.push(view.bar);
399
+ if (showStats) parts.push(theme.fg("dim", formatCompactionTokens(view)));
400
+ if (parts.length === 0) return INVISIBLE;
401
+ return { content: parts.join(" "), visible: true };
402
+ },
403
+ };
404
+
353
405
  const accentAnsi = new Map<string, string>();
354
406
 
355
407
  const sessionNameSegment: StatusLineSegment = {
@@ -384,6 +436,8 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
384
436
  context_graph: contextGraphSegment,
385
437
  scroll_hint: scrollHintSegment,
386
438
  working: workingSegment,
439
+ compaction_info: compactionInfoSegment,
440
+ compaction_graph: compactionGraphSegment,
387
441
  };
388
442
 
389
443
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
7
7
  import { makeBoxPainters, renderBoxRow } from "./box.js";
8
+ import { COMPACTION_FIT_PROBE } from "./compaction-embed.js";
8
9
  import type { SegmentContextBuilder } from "./context.js";
9
10
  import { buildStatusLine } from "./layout.js";
10
11
  import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
@@ -13,6 +14,7 @@ import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js"
13
14
  import {
14
15
  applySettings,
15
16
  BORDER_STYLES,
17
+ bottomRightSegments,
16
18
  DEFAULT_FEEDS,
17
19
  DEFAULT_SEGMENTS,
18
20
  FEED_FORMATS,
@@ -71,48 +73,64 @@ const SEGMENT_ROWS: readonly {
71
73
  { id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
72
74
  ];
73
75
 
74
- // Feed rows are addressed by index so a rebuild can drop one cleanly.
76
+ // Feed rows carry a stable key (independent of on-screen position) so
77
+ // MenuComponent.rebuild's id-keyed value carry-over survives a removal.
75
78
  const FEED_PREFIX = "feed";
76
- const feedKey = (index: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${index}.${part}`;
79
+ const feedKey = (key: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${key}.${part}`;
77
80
  const ADD_FEED_ID = `${FEED_PREFIX}.add`;
78
- const removeFeedId = (index: number) => `${FEED_PREFIX}.${index}.remove`;
81
+ const removeFeedId = (key: number) => `${FEED_PREFIX}.${key}.remove`;
79
82
  const FEED_ROW_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.`);
83
+ const REMOVE_FEED_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`);
80
84
 
81
- function feedSection(feeds: readonly StatusLineFeed[]): MenuSection {
85
+ interface FeedPair {
86
+ key: number;
87
+ feed: StatusLineFeed;
88
+ }
89
+
90
+ const pairsFromFeeds = (feeds: readonly StatusLineFeed[]): FeedPair[] => feeds.map((feed, key) => ({ key, feed }));
91
+
92
+ function feedSection(pairs: readonly FeedPair[]): MenuSection {
82
93
  const items: MenuSection["items"] = [];
83
- for (const [index, feed] of feeds.entries()) {
94
+ pairs.forEach(({ key, feed }, index) => {
84
95
  const n = index + 1;
85
96
  items.push(
86
- { id: feedKey(index, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
87
- { id: feedKey(index, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
88
- { id: feedKey(index, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
89
- { id: feedKey(index, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
90
- { id: removeFeedId(index), label: `${n}. remove this feed`, value: false, action: true },
97
+ { id: feedKey(key, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
98
+ { id: feedKey(key, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
99
+ { id: feedKey(key, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
100
+ { id: feedKey(key, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
101
+ { id: removeFeedId(key), label: `${n}. remove this feed`, value: false, action: true },
91
102
  );
92
- }
103
+ });
93
104
  items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
94
105
  return { title: "Feeds", items };
95
106
  }
96
107
 
97
- /** Read the feed rows back out of the menu's flat value map. */
98
- function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
99
- const indices = new Set<number>();
100
- for (const key of Object.keys(values)) {
101
- const match = FEED_ROW_RE.exec(key);
102
- if (match) indices.add(Number(match[1]));
108
+ /** Read the feed rows back out of the menu's flat value map, keyed by their stable id. */
109
+ function feedPairsFromValues(values: Record<string, MenuValue>): FeedPair[] {
110
+ const keys = new Set<number>();
111
+ for (const id of Object.keys(values)) {
112
+ const match = FEED_ROW_RE.exec(id);
113
+ if (match) keys.add(Number(match[1]));
103
114
  }
104
- return [...indices]
115
+ return [...keys]
105
116
  .sort((a, b) => a - b)
106
- .map((index): StatusLineFeed => {
107
- const rawFormat = String(values[feedKey(index, "format")] ?? "text");
117
+ .map((key): FeedPair => {
118
+ const rawFormat = String(values[feedKey(key, "format")] ?? "text");
108
119
  return {
109
- customType: String(values[feedKey(index, "customType")] ?? ""),
110
- field: String(values[feedKey(index, "field")] ?? ""),
111
- prefix: String(values[feedKey(index, "prefix")] ?? ""),
112
- format: FEED_FORMATS.includes(rawFormat as FeedFormat) ? (rawFormat as FeedFormat) : "text",
120
+ key,
121
+ feed: {
122
+ customType: String(values[feedKey(key, "customType")] ?? ""),
123
+ field: String(values[feedKey(key, "field")] ?? ""),
124
+ prefix: String(values[feedKey(key, "prefix")] ?? ""),
125
+ format: FEED_FORMATS.includes(rawFormat as FeedFormat) ? (rawFormat as FeedFormat) : "text",
126
+ },
113
127
  };
114
128
  })
115
- .filter(feed => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
129
+ .filter(({ feed }) => feed.customType.trim() || feed.field.trim() || feed.prefix.trim());
130
+ }
131
+
132
+ function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
133
+ return feedPairsFromValues(values).map(({ feed }) => feed);
116
134
  }
117
135
 
118
136
  function buildSections(settings: StatusLineSettings): MenuSection[] {
@@ -159,6 +177,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
159
177
  label: "Embed status spinners",
160
178
  value: settings.embedWorkingStatus ?? false,
161
179
  },
180
+ {
181
+ id: "embedCompactionProgress",
182
+ label: "Embed compaction progress",
183
+ value: settings.embedCompactionProgress ?? true,
184
+ },
162
185
  ],
163
186
  };
164
187
  return [
@@ -171,7 +194,7 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
171
194
  value: seg[row.id],
172
195
  })),
173
196
  })),
174
- feedSection(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS),
197
+ feedSection(pairsFromFeeds(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS)),
175
198
  ];
176
199
  }
177
200
 
@@ -192,6 +215,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
192
215
  nvidiaGreenBorder: values.nvidiaGreenBorder === true,
193
216
  nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
194
217
  embedWorkingStatus: values.embedWorkingStatus === true,
218
+ embedCompactionProgress: values.embedCompactionProgress === true,
195
219
  };
196
220
  }
197
221
 
@@ -312,14 +336,17 @@ class StatusLinePreview {
312
336
  { col: 3, row: 0 },
313
337
  { workingEllipsis: compactionDemo ? "…" : "" },
314
338
  );
339
+ // Focusing the compaction row demos the bottom bar hosting
340
+ // pi-topping-compact's progress, while the setting is on.
341
+ const progressDemo = activeItemId === "embedCompactionProgress" && effective.embedCompactionProgress;
315
342
  const bottom = buildStatusLine(
316
343
  barWidth,
317
- ctx,
344
+ progressDemo ? { ...ctx, compaction: COMPACTION_FIT_PROBE } : ctx,
318
345
  effective,
319
346
  painters.gapColor,
320
347
  {
321
348
  left: effective.bottomLeftSegments,
322
- right: effective.bottomRightSegments,
349
+ right: bottomRightSegments(effective, progressDemo),
323
350
  },
324
351
  { col: 3, row: bottomIdx },
325
352
  );
@@ -347,7 +374,7 @@ export function registerSettingsCommand(
347
374
  onChange: () => void,
348
375
  ): void {
349
376
  pi.registerCommand("topping-statusline-settings", {
350
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, and the embedded status spinners",
377
+ description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, the Switchyard NVIDIA-green border, the embedded status spinners, and the embedded compaction progress",
351
378
  handler: async (_args, ctx: ExtensionCommandContext) => {
352
379
  if (ctx.mode !== "tui") {
353
380
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
@@ -360,17 +387,20 @@ export function registerSettingsCommand(
360
387
  hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
361
388
  preview: preview.render.bind(preview),
362
389
  onAction: (id, values) => {
363
- const feeds = feedsFromValues(values);
390
+ const pairs = feedPairsFromValues(values);
391
+ let nextPairs: FeedPair[];
364
392
  if (id === ADD_FEED_ID) {
365
- feeds.push({ customType: "", field: "", prefix: "", format: "currency" });
393
+ const key = Math.max(-1, ...pairs.map(p => p.key)) + 1;
394
+ nextPairs = [...pairs, { key, feed: { customType: "", field: "", prefix: "", format: "currency" } }];
366
395
  } else {
367
- const match = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`).exec(id);
396
+ const match = REMOVE_FEED_RE.exec(id);
368
397
  if (!match) return undefined;
369
- feeds.splice(Number(match[1]), 1);
398
+ const removedKey = Number(match[1]);
399
+ nextPairs = pairs.filter(p => p.key !== removedKey);
370
400
  }
371
- // Rows are index-addressed, so the whole menu is regenerated to
372
- // keep ids contiguous after an insert or removal.
373
- return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(feeds)];
401
+ // The whole menu is regenerated so positional labels stay correct
402
+ // after an insert or removal; row ids are untouched by this.
403
+ return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(nextPairs)];
374
404
  },
375
405
  });
376
406
  if (!result.applied) return;