@underactive/pi-topping-statusline 0.2.0 → 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.
@@ -5,13 +5,16 @@
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";
12
+ import { NvidiaGreenBorder, isSwitchyardProvider } from "./nvidia-green.js";
11
13
  import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
12
14
  import {
13
15
  applySettings,
14
16
  BORDER_STYLES,
17
+ bottomRightSegments,
15
18
  DEFAULT_FEEDS,
16
19
  DEFAULT_SEGMENTS,
17
20
  FEED_FORMATS,
@@ -34,8 +37,10 @@ import type {
34
37
  } from "./types.js";
35
38
 
36
39
  const SYMBOL_LABELS: Record<SymbolPreset, string> = { nerd: "nerdfont", unicode: "unicode", ascii: "ascii" };
37
- const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = { nerdfont: "nerd", unicode: "unicode", ascii: "ascii" };
38
- const SYMBOL_VALUES: readonly string[] = ["nerdfont", "unicode", "ascii"];
40
+ const SYMBOL_VALUES: readonly string[] = Object.values(SYMBOL_LABELS);
41
+ const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = Object.fromEntries(
42
+ (Object.entries(SYMBOL_LABELS) as [SymbolPreset, string][]).map(([preset, label]) => [label, preset]),
43
+ );
39
44
 
40
45
  const SECTION_TITLES = [
41
46
  "Top Left Segment Group",
@@ -68,48 +73,64 @@ const SEGMENT_ROWS: readonly {
68
73
  { id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
69
74
  ];
70
75
 
71
- // 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.
72
78
  const FEED_PREFIX = "feed";
73
- const feedKey = (index: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${index}.${part}`;
79
+ const feedKey = (key: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${key}.${part}`;
74
80
  const ADD_FEED_ID = `${FEED_PREFIX}.add`;
75
- const removeFeedId = (index: number) => `${FEED_PREFIX}.${index}.remove`;
81
+ const removeFeedId = (key: number) => `${FEED_PREFIX}.${key}.remove`;
76
82
  const FEED_ROW_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.`);
83
+ const REMOVE_FEED_RE = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`);
77
84
 
78
- 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 {
79
93
  const items: MenuSection["items"] = [];
80
- for (const [index, feed] of feeds.entries()) {
94
+ pairs.forEach(({ key, feed }, index) => {
81
95
  const n = index + 1;
82
96
  items.push(
83
- { id: feedKey(index, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
84
- { id: feedKey(index, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
85
- { id: feedKey(index, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
86
- { id: feedKey(index, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
87
- { 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 },
88
102
  );
89
- }
103
+ });
90
104
  items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
91
105
  return { title: "Feeds", items };
92
106
  }
93
107
 
94
- /** Read the feed rows back out of the menu's flat value map. */
95
- function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
96
- const indices = new Set<number>();
97
- for (const key of Object.keys(values)) {
98
- const match = FEED_ROW_RE.exec(key);
99
- 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]));
100
114
  }
101
- return [...indices]
115
+ return [...keys]
102
116
  .sort((a, b) => a - b)
103
- .map((index): StatusLineFeed => {
104
- const rawFormat = String(values[feedKey(index, "format")] ?? "text");
117
+ .map((key): FeedPair => {
118
+ const rawFormat = String(values[feedKey(key, "format")] ?? "text");
105
119
  return {
106
- customType: String(values[feedKey(index, "customType")] ?? ""),
107
- field: String(values[feedKey(index, "field")] ?? ""),
108
- prefix: String(values[feedKey(index, "prefix")] ?? ""),
109
- 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
+ },
110
127
  };
111
128
  })
112
- .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);
113
134
  }
114
135
 
115
136
  function buildSections(settings: StatusLineSettings): MenuSection[] {
@@ -141,11 +162,26 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
141
162
  label: "Animate rainbow border",
142
163
  value: settings.rainbowAnimation ?? true,
143
164
  },
165
+ {
166
+ id: "nvidiaGreenBorder",
167
+ label: "NVIDIA-green when using Switchyard",
168
+ value: settings.nvidiaGreenBorder ?? true,
169
+ },
170
+ {
171
+ id: "nvidiaGreenAnimation",
172
+ label: "Animate Switchyard green border",
173
+ value: settings.nvidiaGreenAnimation ?? true,
174
+ },
144
175
  {
145
176
  id: "embedWorkingStatus",
146
- label: "Embed 'Working' indicator",
177
+ label: "Embed status spinners",
147
178
  value: settings.embedWorkingStatus ?? false,
148
179
  },
180
+ {
181
+ id: "embedCompactionProgress",
182
+ label: "Embed compaction progress",
183
+ value: settings.embedCompactionProgress ?? true,
184
+ },
149
185
  ],
150
186
  };
151
187
  return [
@@ -158,7 +194,7 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
158
194
  value: seg[row.id],
159
195
  })),
160
196
  })),
161
- feedSection(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS),
197
+ feedSection(pairsFromFeeds(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS)),
162
198
  ];
163
199
  }
164
200
 
@@ -176,7 +212,10 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
176
212
  feeds: sanitizeFeeds(feedsFromValues(values)),
177
213
  rainbowBorder: values.rainbowBorder === true,
178
214
  rainbowAnimation: values.rainbowAnimation === true,
215
+ nvidiaGreenBorder: values.nvidiaGreenBorder === true,
216
+ nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
179
217
  embedWorkingStatus: values.embedWorkingStatus === true,
218
+ embedCompactionProgress: values.embedCompactionProgress === true,
180
219
  };
181
220
  }
182
221
 
@@ -193,6 +232,8 @@ const CANNED_PERCENT = 42;
193
232
  const CANNED_WINDOW = 200_000;
194
233
  /** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
195
234
  const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
235
+ /** pi 0.86's compaction spinner: the longest status the bar has to carry. */
236
+ const CANNED_COMPACTION = "⠙ Context overflow detected, Auto-compacting... (esc to cancel)";
196
237
 
197
238
  /** Fill any feed the live session has no entry for, so the preview stays legible. */
198
239
  function cannedFeedData(
@@ -221,7 +262,7 @@ class StatusLinePreview {
221
262
  render(
222
263
  values: Record<string, MenuValue>,
223
264
  elapsedMs: number,
224
- _activeItemId: string | undefined,
265
+ activeItemId: string | undefined,
225
266
  innerWidth: number,
226
267
  ): PreviewResult {
227
268
  const effective = resolveEffectiveSettings(valuesToSettings(values));
@@ -229,8 +270,6 @@ class StatusLinePreview {
229
270
  // 6-cell chrome budget for the bars themselves.
230
271
  const boxWidth = Math.max(24, innerWidth - 1);
231
272
  const barWidth = boxWidth - 6;
232
- const rainbowOn = effective.rainbowBorder;
233
- const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
234
273
 
235
274
  // The theme singleton drives glyph lookups everywhere, so swap the
236
275
  // symbol preset for this synchronous render only; the live bar behind
@@ -245,6 +284,10 @@ class StatusLinePreview {
245
284
  feeds: effective.segmentOptions.feeds.map(f => f.customType),
246
285
  };
247
286
  const base = this.#builder.build(barWidth, effective.segmentOptions, include, CANNED_HINT);
287
+ const greenOn = effective.nvidiaGreenBorder && isSwitchyardProvider(base.model?.provider);
288
+ const rainbowOn = !greenOn && effective.rainbowBorder;
289
+ const animationOn = greenOn ? effective.nvidiaGreenAnimation : rainbowOn && effective.rainbowAnimation;
290
+ const phase = animationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0;
248
291
  const contextWindow = base.contextWindow || CANNED_WINDOW;
249
292
  const ctx: SegmentContext = {
250
293
  ...base,
@@ -267,18 +310,23 @@ class StatusLinePreview {
267
310
  const box = theme.getBox(effective.borderStyle);
268
311
  // The preview box is three rows tall: top bar, side verticals, bottom bar.
269
312
  const bottomIdx = 2;
313
+ const colorizer = greenOn ? new NvidiaGreenBorder(phase) : new RainbowBorder(phase);
270
314
  const painters = makeBoxPainters({
271
- rainbowOn,
272
- rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
315
+ colorizerOn: greenOn || rainbowOn,
316
+ colorizer,
273
317
  box,
274
318
  width: boxWidth,
275
319
  bottomIdx,
276
320
  flat: s => this.#uiTheme.fg("border", s),
277
321
  });
278
322
  const embedOn = effective.embedWorkingStatus;
323
+ // Focusing the embed row demos the message-style spinners pi 0.86
324
+ // routes through the same slot, including their ellipsis truncation.
325
+ const compactionDemo = activeItemId === "embedWorkingStatus";
326
+ const sample = compactionDemo ? CANNED_COMPACTION : CANNED_WORKING;
279
327
  const top = buildStatusLine(
280
328
  barWidth,
281
- embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", CANNED_WORKING) } : ctx,
329
+ embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", sample) } : ctx,
282
330
  effective,
283
331
  painters.gapColor,
284
332
  {
@@ -286,15 +334,19 @@ class StatusLinePreview {
286
334
  right: effective.rightSegments,
287
335
  },
288
336
  { col: 3, row: 0 },
337
+ { workingEllipsis: compactionDemo ? "…" : "" },
289
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;
290
342
  const bottom = buildStatusLine(
291
343
  barWidth,
292
- ctx,
344
+ progressDemo ? { ...ctx, compaction: COMPACTION_FIT_PROBE } : ctx,
293
345
  effective,
294
346
  painters.gapColor,
295
347
  {
296
348
  left: effective.bottomLeftSegments,
297
- right: effective.bottomRightSegments,
349
+ right: bottomRightSegments(effective, progressDemo),
298
350
  },
299
351
  { col: 3, row: bottomIdx },
300
352
  );
@@ -306,8 +358,8 @@ class StatusLinePreview {
306
358
  painters.paint(1, boxWidth - 1, box.vertical),
307
359
  renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
308
360
  ],
309
- // A static rainbow stays at phase zero and needs no repaint timer.
310
- nextRefreshInMs: rainbowAnimationOn ? RAINBOW_FRAME_MS : undefined,
361
+ // A static colorizer stays at phase zero and needs no repaint timer.
362
+ nextRefreshInMs: animationOn ? RAINBOW_FRAME_MS : undefined,
311
363
  };
312
364
  } finally {
313
365
  theme.setSymbolPreset(applied);
@@ -322,7 +374,7 @@ export function registerSettingsCommand(
322
374
  onChange: () => void,
323
375
  ): void {
324
376
  pi.registerCommand("topping-statusline-settings", {
325
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded working indicator",
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",
326
378
  handler: async (_args, ctx: ExtensionCommandContext) => {
327
379
  if (ctx.mode !== "tui") {
328
380
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
@@ -335,17 +387,20 @@ export function registerSettingsCommand(
335
387
  hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
336
388
  preview: preview.render.bind(preview),
337
389
  onAction: (id, values) => {
338
- const feeds = feedsFromValues(values);
390
+ const pairs = feedPairsFromValues(values);
391
+ let nextPairs: FeedPair[];
339
392
  if (id === ADD_FEED_ID) {
340
- 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" } }];
341
395
  } else {
342
- const match = new RegExp(`^${FEED_PREFIX}\\.(\\d+)\\.remove$`).exec(id);
396
+ const match = REMOVE_FEED_RE.exec(id);
343
397
  if (!match) return undefined;
344
- feeds.splice(Number(match[1]), 1);
398
+ const removedKey = Number(match[1]);
399
+ nextPairs = pairs.filter(p => p.key !== removedKey);
345
400
  }
346
- // Rows are index-addressed, so the whole menu is regenerated to
347
- // keep ids contiguous after an insert or removal.
348
- 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)];
349
404
  },
350
405
  });
351
406
  if (!result.applied) return;
package/src/settings.ts CHANGED
@@ -93,7 +93,10 @@ function loadSettings(): StatusLineSettings {
93
93
  if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
94
94
  if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
95
95
  if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
96
+ if (typeof raw.nvidiaGreenBorder === "boolean") settings.nvidiaGreenBorder = raw.nvidiaGreenBorder;
97
+ if (typeof raw.nvidiaGreenAnimation === "boolean") settings.nvidiaGreenAnimation = raw.nvidiaGreenAnimation;
96
98
  if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
99
+ if (typeof raw.embedCompactionProgress === "boolean") settings.embedCompactionProgress = raw.embedCompactionProgress;
97
100
  return settings;
98
101
  } catch {
99
102
  // Missing or corrupt settings.json falls back to defaults.
@@ -156,7 +159,10 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
156
159
  bottomRightSegments,
157
160
  rainbowBorder: settings.rainbowBorder ?? true,
158
161
  rainbowAnimation: settings.rainbowAnimation ?? true,
162
+ nvidiaGreenBorder: settings.nvidiaGreenBorder ?? true,
163
+ nvidiaGreenAnimation: settings.nvidiaGreenAnimation ?? true,
159
164
  embedWorkingStatus: settings.embedWorkingStatus ?? false,
165
+ embedCompactionProgress: settings.embedCompactionProgress ?? true,
160
166
  includes,
161
167
  segmentOptions: {
162
168
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
@@ -167,14 +173,34 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
167
173
  };
168
174
  }
169
175
 
176
+ /** Segments that remain visible, unfaded, while a pi status spinner is embedded. */
177
+ export const STATUS_STABLE_SEGMENTS: ReadonlySet<StatusLineSegmentId> = new Set(["pi", "model"]);
178
+
170
179
  /**
171
- * The top-left group for one frame. While pi's working status is embedded,
172
- * it stands in for every left segment except the leading Pi symbol, so the
173
- * symbol and its chevron never move when a stream starts or ends.
180
+ * The top-left group for one frame. While a pi status spinner is embedded, it
181
+ * stands in for the path and repository segments but keeps the leading Pi
182
+ * symbol and configured model details visible.
174
183
  */
175
184
  export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
176
185
  if (!working) return effective.leftSegments;
177
- return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
186
+ const segments = effective.leftSegments.filter((id) => STATUS_STABLE_SEGMENTS.has(id));
187
+ segments.splice(segments.indexOf("pi") + 1, 0, "working");
188
+ return segments;
189
+ }
190
+
191
+ /** Bottom-right segments a hosted compaction stands in for, and their stand-ins. */
192
+ export const COMPACTION_STAND_INS: Partial<Record<StatusLineSegmentId, StatusLineSegmentId>> = {
193
+ pi_stats: "compaction_info",
194
+ context_graph: "compaction_graph",
195
+ };
196
+
197
+ /**
198
+ * The bottom-right group for one frame. While pi-topping-compact's progress is
199
+ * hosted, its figures stand in for pi's stats and its bar for the context graph.
200
+ */
201
+ export function bottomRightSegments(effective: EffectiveStatusLineSettings, compacting: boolean): StatusLineSegmentId[] {
202
+ if (!compacting) return effective.bottomRightSegments;
203
+ return effective.bottomRightSegments.map((id) => COMPACTION_STAND_INS[id] ?? id);
178
204
  }
179
205
 
180
206
  export interface SettingsState {
@@ -24,8 +24,8 @@ const ROUNDED: BoxGlyphs = {
24
24
 
25
25
  const identityPainters = (boxWidth: number) =>
26
26
  makeBoxPainters({
27
- rainbowOn: false,
28
- rainbow: new RainbowBorder(0),
27
+ colorizerOn: false,
28
+ colorizer: new RainbowBorder(0),
29
29
  box: ROUNDED,
30
30
  width: boxWidth,
31
31
  bottomIdx: 2,
@@ -71,8 +71,8 @@ test("a bar wider than the budget clamps pad to zero", () => {
71
71
 
72
72
  test("non-rainbow painters wrap whole runs in one flat call", () => {
73
73
  const painters = makeBoxPainters({
74
- rainbowOn: false,
75
- rainbow: new RainbowBorder(0),
74
+ colorizerOn: false,
75
+ colorizer: new RainbowBorder(0),
76
76
  box: ROUNDED,
77
77
  width: 20,
78
78
  bottomIdx: 2,
@@ -85,8 +85,8 @@ test("non-rainbow painters wrap whole runs in one flat call", () => {
85
85
 
86
86
  test("rainbow coloring never changes the geometry", () => {
87
87
  const painters = makeBoxPainters({
88
- rainbowOn: true,
89
- rainbow: new RainbowBorder(0),
88
+ colorizerOn: true,
89
+ colorizer: new RainbowBorder(0),
90
90
  box: ROUNDED,
91
91
  width: 20,
92
92
  bottomIdx: 2,
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Contracts for hosting pi-topping-compact's compaction progress: the broadcast
3
+ * parser, the preconditions for announcing that the bar hosts it, the
4
+ * bottom-right stand-ins, and the two segments that draw the bar and figures.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
9
+ import { hostsCompactionProgress, parseCompactionProgress } from "../compaction-embed.ts";
10
+ import { stripAnsi } from "../footer.ts";
11
+ import { buildStatusLine } from "../layout.ts";
12
+ import { formatCompactionInfo, formatCompactionTokens, SEGMENTS } from "../segments.ts";
13
+ import { bottomRightSegments, resolveEffectiveSettings } from "../settings.ts";
14
+ import { theme } from "../theme.ts";
15
+ import type { CompactionProgressView, SegmentContext } from "../types.ts";
16
+
17
+ const noGap = (s: string) => s;
18
+ const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
19
+
20
+ const BAR = `\x1b[38;2;114;241;184m${"▋".repeat(7)}\x1b[39m\x1b[2m${"░".repeat(13)}\x1b[22m\x1b[39m`;
21
+ const VIEW: CompactionProgressView = {
22
+ bar: BAR,
23
+ percent: 47,
24
+ tokensBefore: 86_000,
25
+ contextWindow: 131_072,
26
+ elapsedMs: 16_300,
27
+ };
28
+ const DOT = theme.sep.dot;
29
+
30
+ function ctxWith(compaction: CompactionProgressView | undefined): SegmentContext {
31
+ return {
32
+ options: EFFECTIVE.segmentOptions,
33
+ model: undefined,
34
+ thinkingLevel: "off",
35
+ cwd: "/tmp/pi-topping-statusline-tests",
36
+ sessionName: undefined,
37
+ contextPercent: 41,
38
+ contextTokens: 53_740,
39
+ contextWindow: 131_072,
40
+ git: { branch: null, status: null, pr: null },
41
+ worktree: null,
42
+ scrollHint: undefined,
43
+ piStats: "↑ 142K ↓ 16K R 4.2M",
44
+ tokenRate: undefined,
45
+ feedData: undefined,
46
+ compaction,
47
+ };
48
+ }
49
+
50
+ test("parseCompactionProgress accepts an active broadcast and rejects everything else", () => {
51
+ assert.deepEqual(
52
+ parseCompactionProgress({ active: true, ...VIEW, phase: "summarizing", remainingPercent: 35 }),
53
+ VIEW,
54
+ "the rest of the snapshot is ignored",
55
+ );
56
+ assert.equal(parseCompactionProgress({ active: false }), undefined);
57
+ assert.equal(parseCompactionProgress({ ...VIEW, active: "true" }), undefined);
58
+ assert.equal(parseCompactionProgress({ active: true, ...VIEW, bar: 7 }), undefined);
59
+ assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: undefined }), undefined);
60
+ assert.equal(parseCompactionProgress({ active: true, ...VIEW, percent: Number.NaN }), undefined);
61
+ assert.equal(parseCompactionProgress({ active: true, ...VIEW, tokensBefore: "86000" }), undefined);
62
+ assert.equal(parseCompactionProgress({ active: true, ...VIEW, elapsedMs: "16.3" }), undefined);
63
+ assert.equal(parseCompactionProgress(undefined), undefined);
64
+ assert.equal(parseCompactionProgress(null), undefined);
65
+ assert.equal(parseCompactionProgress([{ active: true, ...VIEW }]), undefined);
66
+ assert.equal(parseCompactionProgress("active"), undefined);
67
+ });
68
+
69
+ test("embedCompactionProgress resolves on by default and off when set", () => {
70
+ assert.equal(resolveEffectiveSettings({}).embedCompactionProgress, true);
71
+ assert.equal(resolveEffectiveSettings({ embedCompactionProgress: false }).embedCompactionProgress, false);
72
+ });
73
+
74
+ test("the bar hosts the progress only with the setting on and a stand-in slot shown", () => {
75
+ assert.equal(hostsCompactionProgress(EFFECTIVE), true);
76
+ assert.equal(hostsCompactionProgress(resolveEffectiveSettings({ embedCompactionProgress: false })), false);
77
+ const graphOnly = resolveEffectiveSettings({ segments: { piStats: false } });
78
+ assert.equal(hostsCompactionProgress(graphOnly), true, "the context graph alone hosts the bar");
79
+ const statsOnly = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } });
80
+ assert.equal(hostsCompactionProgress(statsOnly), true, "pi stats alone host the figures");
81
+ const nowhere = resolveEffectiveSettings({ segments: { piStats: false, contextBar: false, contextStats: false } });
82
+ assert.equal(hostsCompactionProgress(nowhere), false);
83
+ });
84
+
85
+ test("bottomRightSegments swaps in the stand-ins in place while compacting", () => {
86
+ const effective = resolveEffectiveSettings({ segments: { feeds: true, tokenRate: true } });
87
+ assert.deepEqual(effective.bottomRightSegments, ["feeds", "token_rate", "pi_stats", "context_graph"]);
88
+ assert.deepEqual(bottomRightSegments(effective, true), [
89
+ "feeds",
90
+ "token_rate",
91
+ "compaction_info",
92
+ "compaction_graph",
93
+ ]);
94
+ assert.equal(bottomRightSegments(effective, false), effective.bottomRightSegments);
95
+ });
96
+
97
+ test("formatCompactionInfo reads how much is summarized and the time taken", () => {
98
+ assert.equal(formatCompactionInfo(VIEW), `47% summarized${DOT}16.3s`);
99
+ assert.equal(formatCompactionInfo({ ...VIEW, percent: 0, elapsedMs: 0 }), `0% summarized${DOT}0.0s`);
100
+ assert.equal(formatCompactionInfo({ ...VIEW, percent: 33.4, elapsedMs: 1_234_567 }), `33% summarized${DOT}1234.6s`);
101
+ assert.equal(formatCompactionInfo({ ...VIEW, percent: 140 }), `100% summarized${DOT}16.3s`, "clamped high");
102
+ assert.equal(formatCompactionInfo({ ...VIEW, percent: -3, elapsedMs: -5 }), `0% summarized${DOT}0.0s`, "clamped low");
103
+ });
104
+
105
+ test("formatCompactionTokens uses the context label's number style", () => {
106
+ assert.equal(formatCompactionTokens(VIEW), "86K/131K (66%)");
107
+ assert.equal(
108
+ formatCompactionTokens({ ...VIEW, tokensBefore: 1_250_000, contextWindow: 2_000_000 }),
109
+ "1.3M/2.0M (63%)",
110
+ );
111
+ assert.equal(formatCompactionTokens({ ...VIEW, contextWindow: 0 }), "86K/?", "no share of an unknown window");
112
+ assert.equal(formatCompactionTokens({ ...VIEW, tokensBefore: 200_000 }), "200K/131K (100%)", "clamped");
113
+ });
114
+
115
+ test("compaction segments are invisible without a hosted compaction", () => {
116
+ assert.equal(SEGMENTS.compaction_info.render(ctxWith(undefined)).visible, false);
117
+ assert.equal(SEGMENTS.compaction_graph.render(ctxWith(undefined)).visible, false);
118
+ });
119
+
120
+ test("compaction_info renders the figures in dim text", () => {
121
+ assert.deepEqual(SEGMENTS.compaction_info.render(ctxWith(VIEW)), {
122
+ content: theme.fg("dim", formatCompactionInfo(VIEW)),
123
+ visible: true,
124
+ });
125
+ });
126
+
127
+ test("compaction_graph passes the bar through verbatim and honors the graph toggles", () => {
128
+ const both = SEGMENTS.compaction_graph.render(ctxWith(VIEW));
129
+ assert.ok(both.visible && both.content.startsWith(BAR), "bar first, untouched");
130
+ assert.equal(stripAnsi(both.content), `${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`);
131
+
132
+ const barOnly = ctxWith(VIEW);
133
+ barOnly.options = resolveEffectiveSettings({ segments: { contextStats: false } }).segmentOptions;
134
+ assert.equal(SEGMENTS.compaction_graph.render(barOnly).content, BAR);
135
+
136
+ const statsOnly = ctxWith(VIEW);
137
+ statsOnly.options = resolveEffectiveSettings({ segments: { contextBar: false } }).segmentOptions;
138
+ assert.equal(stripAnsi(SEGMENTS.compaction_graph.render(statsOnly).content), "86K/131K (66%)");
139
+
140
+ const neither = ctxWith(VIEW);
141
+ neither.options = resolveEffectiveSettings({ segments: { contextBar: false, contextStats: false } }).segmentOptions;
142
+ assert.equal(SEGMENTS.compaction_graph.render(neither).visible, false);
143
+ });
144
+
145
+ test("the bottom bar swaps pi's stats and the context graph for the compaction while hosted", () => {
146
+ const groups = (compacting: boolean) => ({
147
+ left: EFFECTIVE.bottomLeftSegments,
148
+ right: bottomRightSegments(EFFECTIVE, compacting),
149
+ });
150
+ const idle = stripAnsi(buildStatusLine(100, ctxWith(undefined), EFFECTIVE, noGap, groups(false)));
151
+ assert.ok(idle.includes("↑ 142K"), "pi stats show while idle");
152
+ assert.ok(idle.includes("41.0%/131K"), "the context label shows while idle");
153
+
154
+ const bar = buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups(true));
155
+ const plain = stripAnsi(bar);
156
+ assert.equal(visibleWidth(bar), 100);
157
+ assert.ok(plain.includes(`47% summarized${DOT}16.3s`), "the compaction figures replace pi stats");
158
+ assert.ok(plain.includes(`${"▋".repeat(7)}${"░".repeat(13)} 86K/131K (66%)`), "the compaction bar replaces the graph");
159
+ assert.ok(!plain.includes("↑ 142K"), "pi stats are gone");
160
+ assert.ok(!plain.includes("41.0%"), "the context label is gone");
161
+ assert.ok(bar.includes(BAR), "the bar's own colors survive the layout");
162
+ });
163
+
164
+ test("onFit reports which segments actually survived overflow trimming, not just what was requested", () => {
165
+ const groups = { left: [], right: bottomRightSegments(EFFECTIVE, true) };
166
+ let wide: { left: readonly string[]; right: readonly string[] } | undefined;
167
+ buildStatusLine(100, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
168
+ onFit: fit => {
169
+ wide = fit;
170
+ },
171
+ });
172
+ assert.deepEqual(wide?.right, ["compaction_info", "compaction_graph"], "both stand-ins fit at full width");
173
+
174
+ let narrow: { left: readonly string[]; right: readonly string[] } | undefined;
175
+ buildStatusLine(20, ctxWith(VIEW), EFFECTIVE, noGap, groups, undefined, {
176
+ onFit: fit => {
177
+ narrow = fit;
178
+ },
179
+ });
180
+ assert.ok(
181
+ !narrow?.right.includes("compaction_info") && !narrow?.right.includes("compaction_graph"),
182
+ "too narrow for either stand-in, so a caller checking onFit knows not to claim it hosts the progress",
183
+ );
184
+ });