@underactive/pi-topping-statusline 0.1.3 → 0.2.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.
package/src/layout.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * The bar layout engine, ported from oh-my-pi component.ts #buildStatusLine.
3
3
  *
4
- * Overflow strategy (in order): drop right segments right-to-left, shrink the
5
- * elastic `path` segment down to ~8 cells, drop left segments end-first while
6
- * protecting `path`. The gap between the two groups is filled with the
4
+ * Overflow strategy (in order): drop right segments right-to-left, truncate
5
+ * the embedded `working` status (no ellipsis, as pi's own border does), shrink
6
+ * the elastic `path` segment down to ~8 cells, drop left segments end-first
7
+ * while protecting `path`. The gap between the two groups is filled with the
7
8
  * box-horizontal glyph colored like the editor border, so it tracks the
8
9
  * thinking-level border color; the callback receives the gap's absolute
9
10
  * column and row in the box, so a caller can paint it as part of a
10
11
  * continuous border gradient (the rainbow effect).
12
+ *
13
+ * `options.leftFade` (0..1) blends every left segment after a leading `pi`
14
+ * symbol toward the bar background, which the working-indicator transition
15
+ * uses to cross-fade the group without moving the symbol or its chevron.
11
16
  */
12
- import { visibleWidth } from "@earendil-works/pi-tui";
17
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
18
  import { renderSegment } from "./segments.js";
14
19
  import { getSeparator } from "./separators.js";
15
20
  import { theme } from "./theme.js";
@@ -58,6 +63,7 @@ export function buildStatusLine(
58
63
  gapBorderColor: (str: string, startCol: number, row: number) => string,
59
64
  segmentGroups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] },
60
65
  barOrigin: { col: number; row: number } = { col: 0, row: 0 },
66
+ options: { leftFade?: number } = {},
61
67
  ): string {
62
68
  const separatorDef = getSeparator(settings.separator);
63
69
 
@@ -112,6 +118,19 @@ export function buildStatusLine(
112
118
  right.pop();
113
119
  rightWidth = rightGroupWidth(right);
114
120
  }
121
+ // The working status absorbs overflow first so the Pi symbol beside it
122
+ // survives on narrow terminals.
123
+ const workingIdx = leftSegIds.indexOf("working");
124
+ if (workingIdx >= 0 && totalWidth() > width) {
125
+ const available = visibleWidth(left[workingIdx]) - (totalWidth() - width);
126
+ if (available >= 1) {
127
+ left[workingIdx] = truncateToWidth(left[workingIdx], available, "");
128
+ } else {
129
+ left.splice(workingIdx, 1);
130
+ leftSegIds.splice(workingIdx, 1);
131
+ }
132
+ leftWidth = leftGroupWidth(left);
133
+ }
115
134
  // Shrink path before dropping left segments — path is the only elastic segment
116
135
  const pathIdx = leftSegIds.indexOf("path");
117
136
  if (pathIdx >= 0 && totalWidth() > width) {
@@ -137,13 +156,25 @@ export function buildStatusLine(
137
156
  }
138
157
  }
139
158
 
140
- const renderGroup = (parts: string[], direction: "left" | "right"): string => {
159
+ const renderGroup = (parts: string[], direction: "left" | "right", fadeFrom = parts.length): string => {
141
160
  if (parts.length === 0) return "";
142
161
  const sep = direction === "left" ? separatorDef.left : separatorDef.right;
143
162
  const capPrefix = bgAnsi.replace("\x1b[48;", "\x1b[38;");
144
-
163
+ const sepText = ` ${sepAnsi}${sep}${fgAnsi} `;
164
+
165
+ let body: string;
166
+ const fade = options.leftFade;
167
+ if (fade !== undefined && fade < 1 && fadeFrom < parts.length) {
168
+ // The faded tail opens with an explicit default-fg reset so plain text
169
+ // inside it is recolored too, not just segments that set their own color.
170
+ const head = parts.slice(0, fadeFrom).join(sepText);
171
+ const tail = `\x1b[39m${parts.slice(fadeFrom).join(sepText)}`;
172
+ body = head + theme.fadeAnsi((head ? sepText : "") + tail, fade);
173
+ } else {
174
+ body = parts.join(sepText);
175
+ }
145
176
  let content = bgAnsi + fgAnsi;
146
- content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
177
+ content += ` ${body} `;
147
178
  content += "\x1b[0m";
148
179
 
149
180
  if (capLeft) content = `${capPrefix}${capLeft}\x1b[0m${content}`;
@@ -151,7 +182,7 @@ export function buildStatusLine(
151
182
  return content;
152
183
  };
153
184
 
154
- const leftGroup = renderGroup(left, "left");
185
+ const leftGroup = renderGroup(left, "left", leftSegIds[0] === "pi" ? 1 : 0);
155
186
  const rightGroup = renderGroup(right, "right");
156
187
  if (!leftGroup && !rightGroup) return "";
157
188
 
package/src/segments.ts CHANGED
@@ -13,6 +13,7 @@ import { formatContextUsage } from "./context-thresholds.js";
13
13
  import { MAJOR_COLOR_HEXES, hexToFgAnsi, theme } from "./theme.js";
14
14
  import {
15
15
  clampPathLength,
16
+ feedKey,
16
17
  getSessionAccentHex,
17
18
  sanitizeLabel,
18
19
  sanitizeStatusText,
@@ -256,8 +257,8 @@ function formatFeedValue(value: unknown, format: FeedFormat): string | undefined
256
257
  /**
257
258
  * Values published by other extensions as custom session entries, each shown
258
259
  * as its configured prefix followed by the value. A feed contributes nothing
259
- * when its publisher is absent, has published nothing this run, or reports a
260
- * value the configured format suppresses.
260
+ * when its publisher is absent, has published nothing this run, reports a
261
+ * value the configured format suppresses, or has faded out after five minutes unchanged.
261
262
  */
262
263
  const feedsSegment: StatusLineSegment = {
263
264
  id: "feeds",
@@ -269,11 +270,19 @@ const feedsSegment: StatusLineSegment = {
269
270
  const payload = data[feed.customType];
270
271
  if (payload == null || typeof payload !== "object") continue;
271
272
  if (!Object.hasOwn(payload, feed.field)) continue;
273
+ const display = ctx.feedDisplayState?.[feedKey(feed.customType, feed.field)];
274
+ if (display?.phase === "hidden") continue;
272
275
  const text = formatFeedValue((payload as Record<string, unknown>)[feed.field], feed.format);
273
- if (text !== undefined) parts.push(`${sanitizeLabel(feed.prefix)}${text}`);
276
+ if (text === undefined) continue;
277
+ const content = `${sanitizeLabel(feed.prefix)}${text}`;
278
+ parts.push(
279
+ display?.phase === "fading"
280
+ ? theme.fadeFg("dim", "statusLineBg", display.fadeShade, content)
281
+ : theme.fg("dim", content),
282
+ );
274
283
  }
275
284
  if (parts.length === 0) return INVISIBLE;
276
- return { content: theme.fg("success", parts.join(" ")), visible: true };
285
+ return { content: parts.join(" "), visible: true };
277
286
  },
278
287
  };
279
288
 
@@ -286,6 +295,20 @@ const scrollHintSegment: StatusLineSegment = {
286
295
  },
287
296
  };
288
297
 
298
+ /**
299
+ * Pi's embedded working indicator (spinner + message), already colored by the
300
+ * host, so it is passed through verbatim. The layout truncates it, without an
301
+ * ellipsis, when it overflows, mirroring pi's own border rendering.
302
+ */
303
+ const workingSegment: StatusLineSegment = {
304
+ id: "working",
305
+ render(ctx) {
306
+ const status = ctx.workingStatus;
307
+ if (!status) return INVISIBLE;
308
+ return { content: status, visible: true };
309
+ },
310
+ };
311
+
289
312
  /** Context bar + stats — related parts joined by a space, not the group separator. */
290
313
  const contextGraphSegment: StatusLineSegment = {
291
314
  id: "context_graph",
@@ -342,6 +365,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
342
365
  feeds: feedsSegment,
343
366
  context_graph: contextGraphSegment,
344
367
  scroll_hint: scrollHintSegment,
368
+ working: workingSegment,
345
369
  };
346
370
 
347
371
  export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
@@ -19,6 +19,7 @@ import {
19
19
  saveSettings,
20
20
  sanitizeFeeds,
21
21
  SEPARATORS,
22
+ topLeftSegments,
22
23
  type SettingsState,
23
24
  } from "./settings.js";
24
25
  import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
@@ -140,6 +141,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
140
141
  label: "Animate rainbow border",
141
142
  value: settings.rainbowAnimation ?? true,
142
143
  },
144
+ {
145
+ id: "embedWorkingStatus",
146
+ label: "Embed 'Working' indicator",
147
+ value: settings.embedWorkingStatus ?? false,
148
+ },
143
149
  ],
144
150
  };
145
151
  return [
@@ -170,6 +176,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
170
176
  feeds: sanitizeFeeds(feedsFromValues(values)),
171
177
  rainbowBorder: values.rainbowBorder === true,
172
178
  rainbowAnimation: values.rainbowAnimation === true,
179
+ embedWorkingStatus: values.embedWorkingStatus === true,
173
180
  };
174
181
  }
175
182
 
@@ -184,6 +191,8 @@ const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
184
191
  const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
185
192
  const CANNED_PERCENT = 42;
186
193
  const CANNED_WINDOW = 200_000;
194
+ /** What pi's border indicator looks like mid-stream with pi-topping's loader installed. */
195
+ const CANNED_WORKING = "⠙ Mulling ⢾⣿⣿⣿⣿⣿⢾⢾ 28 tps · 11s · ↓ 316 tokens";
187
196
 
188
197
  /** Fill any feed the live session has no entry for, so the preview stays legible. */
189
198
  function cannedFeedData(
@@ -266,13 +275,14 @@ class StatusLinePreview {
266
275
  bottomIdx,
267
276
  flat: s => this.#uiTheme.fg("border", s),
268
277
  });
278
+ const embedOn = effective.embedWorkingStatus;
269
279
  const top = buildStatusLine(
270
280
  barWidth,
271
- ctx,
281
+ embedOn ? { ...ctx, workingStatus: this.#uiTheme.fg("border", CANNED_WORKING) } : ctx,
272
282
  effective,
273
283
  painters.gapColor,
274
284
  {
275
- left: effective.leftSegments,
285
+ left: topLeftSegments(effective, embedOn),
276
286
  right: effective.rightSegments,
277
287
  },
278
288
  { col: 3, row: 0 },
@@ -312,7 +322,7 @@ export function registerSettingsCommand(
312
322
  onChange: () => void,
313
323
  ): void {
314
324
  pi.registerCommand("topping-statusline-settings", {
315
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border animation",
325
+ description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, rainbow border animation, and the embedded working indicator",
316
326
  handler: async (_args, ctx: ExtensionCommandContext) => {
317
327
  if (ctx.mode !== "tui") {
318
328
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
package/src/settings.ts CHANGED
@@ -93,6 +93,7 @@ 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.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
96
97
  return settings;
97
98
  } catch {
98
99
  // Missing or corrupt settings.json falls back to defaults.
@@ -155,6 +156,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
155
156
  bottomRightSegments,
156
157
  rainbowBorder: settings.rainbowBorder ?? true,
157
158
  rainbowAnimation: settings.rainbowAnimation ?? true,
159
+ embedWorkingStatus: settings.embedWorkingStatus ?? false,
158
160
  includes,
159
161
  segmentOptions: {
160
162
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
@@ -165,6 +167,16 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
165
167
  };
166
168
  }
167
169
 
170
+ /**
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.
174
+ */
175
+ export function topLeftSegments(effective: EffectiveStatusLineSettings, working: boolean): StatusLineSegmentId[] {
176
+ if (!working) return effective.leftSegments;
177
+ return effective.leftSegments[0] === "pi" ? ["pi", "working"] : ["working"];
178
+ }
179
+
168
180
  export interface SettingsState {
169
181
  settings: StatusLineSettings;
170
182
  effective: EffectiveStatusLineSettings;
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
9
9
  import test from "node:test";
10
10
  import { stripVTControlCharacters } from "node:util";
11
11
  import { visibleWidth } from "@earendil-works/pi-tui";
12
- import { makeBoxPainters, renderBoxRow } from "../box.ts";
12
+ import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "../box.ts";
13
13
  import { RainbowBorder } from "../rainbow.ts";
14
14
  import type { BoxGlyphs } from "../theme.ts";
15
15
 
@@ -44,6 +44,19 @@ test("an empty bar renders chrome only, still totalling boxWidth", () => {
44
44
  assert.equal(visibleWidth(out), 20);
45
45
  });
46
46
 
47
+ test("an empty footer bar reserves no row", () => {
48
+ assert.deepEqual(
49
+ renderBoxRowIfVisible(identityPainters(20), "\x1b[32m \x1b[0m", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight),
50
+ [],
51
+ );
52
+ });
53
+
54
+ test("a visible footer bar retains one row", () => {
55
+ const rows = renderBoxRowIfVisible(identityPainters(20), "X", 2, 20, ROUNDED.bottomLeft, ROUNDED.bottomRight);
56
+ assert.equal(rows.length, 1);
57
+ assert.equal(rows[0], "╰──X" + "─".repeat(15) + "╯");
58
+ });
59
+
47
60
  test("wide characters in the bar consume pad cells, keeping the total fixed", () => {
48
61
  const out = renderBoxRow(identityPainters(20), "日本", 0, 20, ROUNDED.topLeft, ROUNDED.topRight);
49
62
  assert.equal(out, "╭──日本" + "─".repeat(12) + "╮");
@@ -12,16 +12,23 @@
12
12
  import assert from "node:assert/strict";
13
13
  import test from "node:test";
14
14
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
15
- import { SegmentContextBuilder } from "../context.ts";
15
+ import { renderBoxRowIfVisible } from "../box.ts";
16
+ import { FEED_FADE_MS, FEED_HOLD_MS, SegmentContextBuilder, getFeedDisplayState } from "../context.ts";
17
+ import { buildStatusLine } from "../layout.ts";
16
18
  import { SEGMENTS } from "../segments.ts";
17
19
  import { DEFAULT_FEEDS, DEFAULT_SEGMENTS, resolveEffectiveSettings, sanitizeFeeds } from "../settings.ts";
18
- import type { SegmentContext, StatusLineFeed } from "../types.ts";
20
+ import { theme } from "../theme.ts";
21
+ import type { FeedDisplayState, SegmentContext, StatusLineFeed } from "../types.ts";
22
+ import { feedKey } from "../utils.ts";
19
23
 
20
24
  const SAVINGS = "pi-prompt-cache/savings";
21
25
  const OPTIONS = resolveEffectiveSettings({}).segmentOptions;
22
26
  const INCLUDE = { git: false, pr: false, piStats: false, tokenRate: false, feeds: [SAVINGS] };
23
27
  /** Outside any repo, so the builder's git probes stay inert. */
24
28
  const CWD = "/tmp/pi-topping-statusline-tests";
29
+ /** Match the builder's intentional prototype-less feed map in deep equality assertions. */
30
+ const feedMap = (entries: Record<string, unknown> = {}): Record<string, unknown> =>
31
+ Object.assign(Object.create(null) as Record<string, unknown>, entries);
25
32
 
26
33
  const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
27
34
  const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
@@ -60,8 +67,11 @@ async function contextAfterEmitting(
60
67
 
61
68
  const BASE = await contextAfterEmitting(() => []);
62
69
  /** Render the segment against explicit feed config and payloads. */
63
- const renderFeeds = (feeds: StatusLineFeed[], feedData: Record<string, unknown> | undefined) =>
64
- SEGMENTS.feeds.render({ ...BASE, options: { ...BASE.options, feeds }, feedData });
70
+ const renderFeeds = (
71
+ feeds: StatusLineFeed[],
72
+ feedData: Record<string, unknown> | undefined,
73
+ feedDisplayState?: Record<string, FeedDisplayState>,
74
+ ) => SEGMENTS.feeds.render({ ...BASE, options: { ...BASE.options, feeds }, feedData, feedDisplayState });
65
75
  const savingsFeed = (format: StatusLineFeed["format"] = "currency", prefix = "CS"): StatusLineFeed => ({
66
76
  customType: SAVINGS,
67
77
  field: "savedUsd",
@@ -71,14 +81,39 @@ const savingsFeed = (format: StatusLineFeed["format"] = "currency", prefix = "CS
71
81
  const renderSaved = (value: unknown, format: StatusLineFeed["format"] = "currency") =>
72
82
  renderFeeds([savingsFeed(format)], { [SAVINGS]: { savedUsd: value } });
73
83
 
84
+ const FOOTER_PAINTERS = {
85
+ paint: (_row: number, _col: number, ch: string) => ch,
86
+ horizRun: (_row: number, _startCol: number, count: number) => "─".repeat(count),
87
+ gapColor: (str: string, _startCol: number, _row: number) => str,
88
+ };
89
+
90
+ /** Build the same one-row bottom-bar path used by index.ts for feed-only cases. */
91
+ const renderFeedRows = (feedData: Record<string, unknown> | undefined, format: StatusLineFeed["format"] = "currency") => {
92
+ const feed = savingsFeed(format);
93
+ const settings = resolveEffectiveSettings({ feeds: [feed] });
94
+ const ctx: SegmentContext = { ...BASE, options: settings.segmentOptions, feedData };
95
+ const bar = buildStatusLine(74, ctx, settings, (str, _startCol, _row) => str, { left: [], right: ["feeds"] });
96
+ return renderBoxRowIfVisible(FOOTER_PAINTERS, bar, 2, 80, "╰", "╯");
97
+ };
98
+
74
99
  // ── reading the feed ───────────────────────────────────────────────────────
75
100
 
76
101
  test("hidden when no publisher has emitted", async () => {
77
102
  const ctx = await contextAfterEmitting(() => []);
78
- assert.deepEqual(ctx.feedData, {});
103
+ assert.deepEqual(ctx.feedData, feedMap());
79
104
  assert.equal(SEGMENTS.feeds.render(ctx).visible, false);
80
105
  });
81
106
 
107
+ test("configured but unavailable or empty feeds reserve no footer row", () => {
108
+ assert.equal(renderFeedRows(undefined).length, 0);
109
+ assert.equal(renderFeedRows({}).length, 0);
110
+ assert.equal(renderFeedRows({ [SAVINGS]: {} }).length, 0);
111
+ });
112
+
113
+ test("a visible feed keeps the footer row", () => {
114
+ assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: 1.25 } }).length, 1);
115
+ });
116
+
82
117
  test("a later custom_message sharing the customType cannot shadow a real entry", async () => {
83
118
  // CustomMessageEntry carries customType too, but keeps its payload under
84
119
  // details. Matching on customType alone would hit this newer entry first,
@@ -94,17 +129,17 @@ test("a later custom_message sharing the customType cannot shadow a real entry",
94
129
  details: { savedUsd: 99 },
95
130
  } as SessionEntry;
96
131
  const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 4.2 }), decoy]);
97
- assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 4.2 } });
132
+ assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 4.2 } }));
98
133
  });
99
134
 
100
135
  test("unsubscribed customTypes are ignored", async () => {
101
136
  const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 9 }, "other-ext/thing")]);
102
- assert.deepEqual(ctx.feedData, {});
137
+ assert.deepEqual(ctx.feedData, feedMap());
103
138
  });
104
139
 
105
140
  test("the newest matching entry wins", async () => {
106
141
  const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 0.5 }), feedEntry({ savedUsd: 1.2345 })]);
107
- assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1.2345 } });
142
+ assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 1.2345 } }));
108
143
  });
109
144
 
110
145
  test("several feeds are collected in one pass and render together", async () => {
@@ -138,12 +173,121 @@ test("missing fields and malformed payloads do not surface", () => {
138
173
  assert.equal(renderFeeds([savingsFeed()], { [SAVINGS]: 5 }).visible, false, "payload must be an object");
139
174
  });
140
175
 
176
+ // ── stale-feed lifecycle ───────────────────────────────────────────────────
177
+
178
+ test("feed display state changes at exact hold and fade boundaries", () => {
179
+ const changedAt = 1_000;
180
+ assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS - 1), { phase: "active", fadeShade: 0 });
181
+ for (let shade = 0; shade < 5; shade++) {
182
+ assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS + shade * (FEED_FADE_MS / 5)), {
183
+ phase: "fading",
184
+ fadeShade: shade,
185
+ });
186
+ }
187
+ assert.deepEqual(getFeedDisplayState(changedAt, changedAt + FEED_HOLD_MS + FEED_FADE_MS), {
188
+ phase: "hidden",
189
+ fadeShade: 4,
190
+ });
191
+ });
192
+
193
+ test("missing display state remains active, while fading and hidden feeds render independently", () => {
194
+ const count: StatusLineFeed = { customType: "my-ext/metrics", field: "count", prefix: "N", format: "number" };
195
+ const feeds = [savingsFeed(), count];
196
+ const data = { [SAVINGS]: { savedUsd: 1.5 }, "my-ext/metrics": { count: 42 } };
197
+ assert.equal(stripAnsi(renderFeeds(feeds, data).content), "CS$1.50 N42");
198
+
199
+ const states = {
200
+ [feedKey(SAVINGS, "savedUsd")]: { phase: "hidden", fadeShade: 4 },
201
+ [feedKey("my-ext/metrics", "count")]: { phase: "fading", fadeShade: 2 },
202
+ } satisfies Record<string, FeedDisplayState>;
203
+ const rendered = renderFeeds(feeds, data, states);
204
+ assert.equal(stripAnsi(rendered.content), "N42");
205
+ assert.equal(rendered.visible, true);
206
+ assert.equal(rendered.content, theme.fadeFg("dim", "statusLineBg", 2, "N42"));
207
+ });
208
+
209
+ test("tracking preserves equal values and resets changed values per field", () => {
210
+ const originalNow = Date.now;
211
+ let now = 1_000_000;
212
+ Date.now = () => now;
213
+ try {
214
+ const entries: SessionEntry[] = [];
215
+ const builder = new SegmentContextBuilder(fakePi);
216
+ builder.attach(fakeCtx(entries));
217
+ entries.push(feedEntry({ savedUsd: { total: 1 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
218
+ const feeds: StatusLineFeed[] = [
219
+ { customType: SAVINGS, field: "savedUsd", prefix: "A", format: "text" },
220
+ { customType: SAVINGS, field: "count", prefix: "B", format: "number" },
221
+ ];
222
+ const options = { ...OPTIONS, feeds };
223
+ let ctx = builder.build(80, options, INCLUDE, undefined);
224
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "active");
225
+
226
+ now += FEED_HOLD_MS + 1;
227
+ entries.push(feedEntry({ savedUsd: { total: 1 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
228
+ ctx = builder.build(80, options, INCLUDE, undefined);
229
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "fading", "cloned equal object must not reset");
230
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")]?.phase, "fading", "equal primitive must not reset");
231
+
232
+ now += 2_001;
233
+ entries.push(feedEntry({ savedUsd: { total: 2 }, count: 2 }, SAVINGS, new Date(now).toISOString()));
234
+ ctx = builder.build(80, options, INCLUDE, undefined);
235
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "savedUsd")]?.phase, "active", "changed hidden/fading field reappears");
236
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")]?.phase, "hidden", "fields sharing a publisher age independently");
237
+
238
+ now += 2_001;
239
+ entries.push(feedEntry({ savedUsd: { total: 2 } }, SAVINGS, new Date(now).toISOString()));
240
+ ctx = builder.build(80, options, INCLUDE, undefined);
241
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")], undefined, "missing fields clear tracking");
242
+ ctx = builder.build(80, { ...options, feeds: [feeds[0]!] }, INCLUDE, undefined);
243
+ assert.equal(ctx.feedDisplayState?.[feedKey(SAVINGS, "count")], undefined, "removed subscriptions stay pruned");
244
+ builder.dispose();
245
+ } finally {
246
+ Date.now = originalNow;
247
+ }
248
+ });
249
+
250
+ test("feed transition timers request a render and are cleared by attach and dispose", () => {
251
+ const originalNow = Date.now;
252
+ const originalSetTimeout = globalThis.setTimeout;
253
+ const originalClearTimeout = globalThis.clearTimeout;
254
+ let scheduled: (() => void) | undefined;
255
+ let cleared = 0;
256
+ Date.now = () => 1_000_000;
257
+ globalThis.setTimeout = ((callback: () => void) => {
258
+ scheduled = callback;
259
+ return { unref() {} } as unknown as ReturnType<typeof setTimeout>;
260
+ }) as typeof setTimeout;
261
+ globalThis.clearTimeout = (() => {
262
+ cleared++;
263
+ }) as typeof clearTimeout;
264
+ try {
265
+ const entries = [feedEntry({ savedUsd: 1 }, SAVINGS, new Date(Date.now()).toISOString())];
266
+ const builder = new SegmentContextBuilder(fakePi);
267
+ let renders = 0;
268
+ builder.setRequestRender(() => renders++);
269
+ builder.attach(fakeCtx(entries));
270
+ builder.build(80, OPTIONS, INCLUDE, undefined);
271
+ assert.ok(scheduled, "an active feed schedules its stale transition");
272
+ scheduled?.();
273
+ assert.equal(renders, 1, "the transition wakes the renderer");
274
+ builder.attach(fakeCtx(entries));
275
+ builder.build(80, OPTIONS, INCLUDE, undefined);
276
+ builder.dispose();
277
+ assert.ok(cleared >= 2, "attach and dispose clear pending feed timers");
278
+ } finally {
279
+ Date.now = originalNow;
280
+ globalThis.setTimeout = originalSetTimeout;
281
+ globalThis.clearTimeout = originalClearTimeout;
282
+ }
283
+ });
284
+
141
285
  // ── the per-run reset ──────────────────────────────────────────────────────
142
286
 
143
287
  test("entries predating this run are stale and render nothing", async () => {
144
288
  const priorRun = new Date(Date.now() - 60_000).toISOString();
145
289
  const ctx = await contextAfterEmitting(() => [feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun)]);
146
- assert.deepEqual(ctx.feedData, {}, "a prior run's figure must not surface");
290
+ assert.deepEqual(ctx.feedData, feedMap(), "a prior run's figure must not surface");
147
291
  });
148
292
 
149
293
  test("the first emission of the new run supersedes a stale one", async () => {
@@ -152,7 +296,7 @@ test("the first emission of the new run supersedes a stale one", async () => {
152
296
  feedEntry({ savedUsd: 7.77 }, SAVINGS, priorRun),
153
297
  feedEntry({ savedUsd: 0.42 }),
154
298
  ]);
155
- assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 0.42 } });
299
+ assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 0.42 } }));
156
300
  });
157
301
 
158
302
  test("session replacement inside the TTL window drops the cached payload", async () => {
@@ -162,7 +306,7 @@ test("session replacement inside the TTL window drops the cached payload", async
162
306
  builder.attach(fakeCtx(entries));
163
307
  await sleep(3);
164
308
  entries.push(feedEntry({ savedUsd: 4.2 }));
165
- assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 4.2 } });
309
+ assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap({ [SAVINGS]: { savedUsd: 4.2 } }));
166
310
 
167
311
  // /new, /resume and /fork replace the session in-process; publishers reset,
168
312
  // so the entries above now belong to a previous run.
@@ -170,7 +314,7 @@ test("session replacement inside the TTL window drops the cached payload", async
170
314
  builder.attach(fakeCtx(entries));
171
315
  assert.deepEqual(
172
316
  builder.build(80, OPTIONS, INCLUDE, undefined).feedData,
173
- {},
317
+ feedMap(),
174
318
  "must rescan on attach rather than serve the prior session's payload",
175
319
  );
176
320
  });
@@ -186,7 +330,7 @@ test("no stale frame is observable across the whole TTL window", async () => {
186
330
  await sleep(3);
187
331
  builder.attach(fakeCtx(entries));
188
332
  for (let frame = 0; frame < 10; frame++) {
189
- assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, {}, `frame ${frame} leaked`);
333
+ assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap(), `frame ${frame} leaked`);
190
334
  await sleep(30);
191
335
  }
192
336
  });
@@ -231,7 +375,7 @@ test("changing the subscription list rescans without waiting out the TTL", async
231
375
  // Adding a feed in the settings menu must take effect on the next frame.
232
376
  const ctx = builder.build(80, OPTIONS, { ...INCLUDE, feeds: [SAVINGS, "my-ext/tokens"] }, undefined);
233
377
  assert.equal(scans, 2);
234
- assert.deepEqual(ctx.feedData, { [SAVINGS]: { savedUsd: 1 }, "my-ext/tokens": { count: 7 } });
378
+ assert.deepEqual(ctx.feedData, feedMap({ [SAVINGS]: { savedUsd: 1 }, "my-ext/tokens": { count: 7 } }));
235
379
  });
236
380
 
237
381
  test("a fresh emission appears once the TTL lapses", async () => {
@@ -242,9 +386,9 @@ test("a fresh emission appears once the TTL lapses", async () => {
242
386
 
243
387
  await sleep(3);
244
388
  entries.push(feedEntry({ savedUsd: 0.01 }));
245
- assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, {}, "still inside the TTL");
389
+ assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap(), "still inside the TTL");
246
390
  await sleep(2_100);
247
- assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, { [SAVINGS]: { savedUsd: 0.01 } });
391
+ assert.deepEqual(builder.build(80, OPTIONS, INCLUDE, undefined).feedData, feedMap({ [SAVINGS]: { savedUsd: 0.01 } }));
248
392
  });
249
393
 
250
394
  // ── formats ────────────────────────────────────────────────────────────────
@@ -260,6 +404,11 @@ test("currency hides sub-cent and negative values, matching the publisher's own
260
404
  }
261
405
  });
262
406
 
407
+ test("format-suppressed feed values reserve no footer row", () => {
408
+ assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: 0.0049 } }).length, 0);
409
+ assert.equal(renderFeedRows({ [SAVINGS]: { savedUsd: "" } }, "text").length, 0);
410
+ });
411
+
263
412
  test("no $0.00 artifact is reachable near the crossing", () => {
264
413
  for (let value = -0.05; value <= 0.05; value += 0.0001) {
265
414
  assert.ok(!stripAnsi(renderSaved(value).content).includes("$0.00"), `rendered $0.00 at ${value}`);
@@ -7,7 +7,13 @@
7
7
  */
8
8
  import assert from "node:assert/strict";
9
9
  import test from "node:test";
10
- import { isBorderRow, stripAnsi, stripRedundantStats } from "../footer.ts";
10
+ import {
11
+ collapseFooterLayoutSlot,
12
+ filterVisibleRows,
13
+ isBorderRow,
14
+ stripAnsi,
15
+ stripRedundantStats,
16
+ } from "../footer.ts";
11
17
 
12
18
  test("isBorderRow accepts a plain border run", () => {
13
19
  assert.equal(isBorderRow("─────"), true);
@@ -35,6 +41,29 @@ test("stripAnsi removes SGR sequences only", () => {
35
41
  assert.equal(stripAnsi("\x1b[32mgreen\x1b[0m plain"), "green plain");
36
42
  });
37
43
 
44
+ test("filterVisibleRows drops empty notifications but keeps visible rows", () => {
45
+ assert.deepEqual(filterVisibleRows(["", " ", "\x1b[32m\x1b[0m", "saved $1.23"]), ["saved $1.23"]);
46
+ });
47
+
48
+ test("fullscreen footer slot can collapse and restores its minimum", () => {
49
+ const footer = { render: () => [] };
50
+ const footerContainer = { children: [footer] };
51
+ const footerEntry = { component: footerContainer, minSize: 1 };
52
+ const dock = { entries: [{ component: {} }, footerEntry] };
53
+ const root = { entries: [{ component: {} }, { component: dock, minSize: 1 }] };
54
+
55
+ const restore = collapseFooterLayoutSlot(root, footer);
56
+ assert.equal(footerEntry.minSize, 0);
57
+ assert.equal(root.entries[1]?.minSize, 1, "must not collapse the whole dock");
58
+ restore?.();
59
+ assert.equal(footerEntry.minSize, 1);
60
+ });
61
+
62
+ test("inline layouts without a footer slot are left untouched", () => {
63
+ assert.equal(collapseFooterLayoutSlot(undefined, {}), undefined);
64
+ assert.equal(collapseFooterLayoutSlot({ children: [] }, {}), undefined);
65
+ });
66
+
38
67
  test("stripRedundantStats passes undefined through", () => {
39
68
  assert.equal(stripRedundantStats(undefined), undefined);
40
69
  });