@underactive/pi-topping-statusline 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,7 +129,10 @@ early in a session, before the cache-write premium is amortized by reads. Above
129
129
 
130
130
  A feed contributes nothing when its publisher is absent, has published nothing this run, or
131
131
  reports a value its format suppresses; the segment disappears entirely once every feed is
132
- quiet, so it costs nothing to leave switched on. Publishers typically reset per process run,
132
+ quiet, so it costs nothing to leave switched on. Feed values are shown in dim text; a value
133
+ that remains unchanged for five minutes fades into the statusline background over 500ms and
134
+ then hides. It reappears in dim text only when that configured field's value changes.
135
+ Publishers typically reset per process run,
133
136
  so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
134
137
  ignored rather than shown as a stale total. During steady-state rendering, the session is
135
138
  re-read at most once every two seconds, because pi copies the whole entry list on each read
package/index.ts CHANGED
@@ -25,9 +25,15 @@ import {
25
25
  type ExtensionContext,
26
26
  } from "@earendil-works/pi-coding-agent";
27
27
  import type { Component, TUI } from "@earendil-works/pi-tui";
28
- import { makeBoxPainters, renderBoxRow } from "./src/box.js";
28
+ import { makeBoxPainters, renderBoxRow, renderBoxRowIfVisible } from "./src/box.js";
29
29
  import { SegmentContextBuilder } from "./src/context.js";
30
- import { isBorderRow, stripAnsi, stripRedundantStats } from "./src/footer.js";
30
+ import {
31
+ collapseFooterLayoutSlot,
32
+ filterVisibleRows,
33
+ isBorderRow,
34
+ stripAnsi,
35
+ stripRedundantStats,
36
+ } from "./src/footer.js";
31
37
  import { buildStatusLine } from "./src/layout.js";
32
38
  import { RAINBOW_DEG_PER_FRAME, RAINBOW_FRAME_MS, RainbowBorder } from "./src/rainbow.js";
33
39
  import { registerSettingsCommand } from "./src/settings-menu.js";
@@ -47,6 +53,7 @@ export default function (pi: ExtensionAPI) {
47
53
  let ensureTimer: ReturnType<typeof setInterval> | undefined;
48
54
  let patchedFooter: Component | undefined;
49
55
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
56
+ let restoreFooterLayout: (() => void) | undefined;
50
57
 
51
58
  const requestRender = () => activeTui?.requestRender();
52
59
  builder.setRequestRender(requestRender);
@@ -149,7 +156,7 @@ export default function (pi: ExtensionAPI) {
149
156
  },
150
157
  { col: 3, row: bottomIdx },
151
158
  );
152
- out.push(renderBoxRow(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
159
+ out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
153
160
  for (let i = bottomIdx + 1; i < lines.length; i++) {
154
161
  out.push(` ${lines[i]}`);
155
162
  }
@@ -163,7 +170,21 @@ export default function (pi: ExtensionAPI) {
163
170
  ? inner(tui, editorTheme, keybindings)
164
171
  : new CustomEditor(tui, editorTheme, keybindings);
165
172
  const innerRender = editor.render.bind(editor);
166
- editor.render = width => renderBoxed(innerRender, width, editor);
173
+ let boxRenderFailed = false;
174
+ editor.render = width => {
175
+ try {
176
+ return renderBoxed(innerRender, width, editor);
177
+ } catch (err) {
178
+ if (!boxRenderFailed) {
179
+ boxRenderFailed = true;
180
+ activeCtx?.ui.notify(
181
+ `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
182
+ "error",
183
+ );
184
+ }
185
+ return innerRender(width);
186
+ }
187
+ };
167
188
  return editor;
168
189
  };
169
190
  };
@@ -212,17 +233,23 @@ export default function (pi: ExtensionAPI) {
212
233
  // its cwd and stats lines, which the box now carries itself.
213
234
  const ensureFooterPatched = () => {
214
235
  const tui = activeTui;
215
- if (!tui || patchedFooter) return;
216
- let footer: Component | undefined;
217
- for (const child of tui.children) {
218
- footer = findFooter(child);
219
- if (footer) break;
236
+ if (!tui) return;
237
+ if (!patchedFooter) {
238
+ let footer: Component | undefined;
239
+ for (const child of tui.children) {
240
+ footer = findFooter(child);
241
+ if (footer) break;
242
+ }
243
+ if (!footer) return;
244
+ const orig = footer.render.bind(footer);
245
+ footerRenderOriginal = orig;
246
+ footer.render = width => filterVisibleRows(orig(width).slice(2));
247
+ patchedFooter = footer;
248
+ }
249
+ if (!restoreFooterLayout) {
250
+ const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
251
+ restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
220
252
  }
221
- if (!footer) return;
222
- const orig = footer.render.bind(footer);
223
- footerRenderOriginal = orig;
224
- footer.render = width => orig(width).slice(2);
225
- patchedFooter = footer;
226
253
  };
227
254
 
228
255
  pi.on("model_select", () => requestRender());
@@ -267,8 +294,10 @@ export default function (pi: ExtensionAPI) {
267
294
  if (patchedFooter && footerRenderOriginal) {
268
295
  patchedFooter.render = footerRenderOriginal;
269
296
  }
297
+ restoreFooterLayout?.();
270
298
  patchedFooter = undefined;
271
299
  footerRenderOriginal = undefined;
300
+ restoreFooterLayout = undefined;
272
301
  ourFactory = undefined;
273
302
  wrappedFactory = undefined;
274
303
  activeTui = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "oh-my-pi's powerline statusline as a pi extension with some customizations",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,9 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-ai": "0.80.10",
49
- "@earendil-works/pi-coding-agent": "0.80.10",
50
- "@earendil-works/pi-tui": "0.80.10",
48
+ "@earendil-works/pi-ai": "0.84.4",
49
+ "@earendil-works/pi-coding-agent": "0.84.4",
50
+ "@earendil-works/pi-tui": "0.84.4",
51
51
  "@types/node": "^22.0.0",
52
52
  "typescript": "^5.7.0"
53
53
  }
package/src/box.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * (host borderColor vs menu theme; shared stepped animator vs phase-from-
10
10
  * elapsed instance).
11
11
  */
12
+ import { stripVTControlCharacters } from "node:util";
12
13
  import { visibleWidth } from "@earendil-works/pi-tui";
13
14
  import { perimeterLength, perimeterPosition, type RainbowBorder } from "./rainbow.js";
14
15
  import type { BoxGlyphs } from "./theme.js";
@@ -67,3 +68,17 @@ export function renderBoxRow(
67
68
  painters.paint(rowIdx, boxWidth - 1, rightGlyph)
68
69
  );
69
70
  }
71
+
72
+ /** Render a box row only when its bar contains visible, non-whitespace content. */
73
+ export function renderBoxRowIfVisible(
74
+ painters: BoxPainters,
75
+ bar: string,
76
+ rowIdx: number,
77
+ boxWidth: number,
78
+ leftGlyph: string,
79
+ rightGlyph: string,
80
+ ): string[] {
81
+ const plainBar = stripVTControlCharacters(bar);
82
+ if (plainBar.trim().length === 0) return [];
83
+ return [renderBoxRow(painters, bar, rowIdx, boxWidth, leftGlyph, rightGlyph)];
84
+ }
package/src/context.ts CHANGED
@@ -7,8 +7,11 @@
7
7
  */
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
+ import { isDeepStrictEqual } from "node:util";
10
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import type { SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
12
+ import { FADE_SHADE_COUNT } from "./theme.js";
13
+ import type { FeedDisplayState, SegmentContext, SegmentIncludes, StatusLineSegmentOptions, TokenRateDisplay } from "./types.js";
14
+ import { feedKey } from "./utils.js";
12
15
 
13
16
  const GIT_STATUS_TTL_MS = 1_000;
14
17
  const BRANCH_POLL_TTL_MS = 5_000;
@@ -16,6 +19,10 @@ const EXEC_TIMEOUT_MS = 2_000;
16
19
 
17
20
  /** getEntries() filters and copies the whole session, so rescans are rate-limited. */
18
21
  const FEED_TTL_MS = 2_000;
22
+ /** Unchanged feed values remain fully visible for five minutes. */
23
+ export const FEED_HOLD_MS = 5 * 60 * 1_000;
24
+ /** Match the existing token-rate fade cadence. */
25
+ export const FEED_FADE_MS = 500;
19
26
 
20
27
  /** Prototype-less map — feed keys are publisher-supplied customTypes. */
21
28
  const emptyFeedData = (): Record<string, unknown> => Object.create(null);
@@ -32,6 +39,22 @@ interface RepoInfo {
32
39
  toplevel: string;
33
40
  }
34
41
 
42
+ interface FeedTracking {
43
+ value: unknown;
44
+ changedAt: number;
45
+ }
46
+
47
+ /** Pure feed lifecycle snapshot, including a clock moving backwards. */
48
+ export function getFeedDisplayState(changedAt: number, now: number): FeedDisplayState {
49
+ const elapsed = Math.max(0, now - changedAt);
50
+ if (elapsed < FEED_HOLD_MS) return { phase: "active", fadeShade: 0 };
51
+ if (elapsed >= FEED_HOLD_MS + FEED_FADE_MS) return { phase: "hidden", fadeShade: FADE_SHADE_COUNT - 1 };
52
+ return {
53
+ phase: "fading",
54
+ fadeShade: Math.min(FADE_SHADE_COUNT - 1, Math.floor((elapsed - FEED_HOLD_MS) / (FEED_FADE_MS / FADE_SHADE_COUNT))),
55
+ };
56
+ }
57
+
35
58
  function deriveContextUsage(ctx: ExtensionContext | undefined): {
36
59
  contextWindow: number;
37
60
  tokens: number | undefined;
@@ -90,8 +113,11 @@ export class SegmentContextBuilder {
90
113
 
91
114
  #feedData: Record<string, unknown> = emptyFeedData();
92
115
  #feedsScannedAt = 0;
93
- #feedsScanned = "";
116
+ #feedsScannedRef: readonly string[] | undefined;
94
117
  #runStartedAt = 0;
118
+ #feedTracking = new Map<string, FeedTracking>();
119
+ #feedTimer: ReturnType<typeof setTimeout> | undefined;
120
+ #feedTimerAt: number | undefined;
95
121
 
96
122
  constructor(pi: ExtensionAPI) {
97
123
  this.#pi = pi;
@@ -118,7 +144,8 @@ export class SegmentContextBuilder {
118
144
  this.#runStartedAt = Date.now();
119
145
  this.#feedData = emptyFeedData();
120
146
  this.#feedsScannedAt = 0;
121
- this.#feedsScanned = "";
147
+ this.#feedsScannedRef = undefined;
148
+ this.#clearFeedLifecycle();
122
149
  if (this.#repoCwd !== ctx.cwd) {
123
150
  this.#resetGitState();
124
151
  this.#repoCwd = ctx.cwd;
@@ -131,6 +158,14 @@ export class SegmentContextBuilder {
131
158
  this.#headWatcher = undefined;
132
159
  this.#watcherOk = false;
133
160
  this.#ctx = undefined;
161
+ this.#clearFeedLifecycle();
162
+ }
163
+
164
+ #clearFeedLifecycle(): void {
165
+ this.#feedTracking.clear();
166
+ if (this.#feedTimer) clearTimeout(this.#feedTimer);
167
+ this.#feedTimer = undefined;
168
+ this.#feedTimerAt = undefined;
134
169
  }
135
170
 
136
171
  // ── git plumbing ─────────────────────────────────────────────────────────
@@ -288,6 +323,55 @@ export class SegmentContextBuilder {
288
323
  }
289
324
  }
290
325
 
326
+ #syncFeedTracking(feeds: StatusLineSegmentOptions["feeds"], now: number): Record<string, FeedDisplayState> {
327
+ const configured = new Set<string>();
328
+ for (const feed of feeds) {
329
+ const key = feedKey(feed.customType, feed.field);
330
+ configured.add(key);
331
+ const payload = this.#feedData[feed.customType];
332
+ if (payload === null || typeof payload !== "object" || !Object.hasOwn(payload, feed.field)) {
333
+ this.#feedTracking.delete(key);
334
+ continue;
335
+ }
336
+ const value = (payload as Record<string, unknown>)[feed.field];
337
+ const prior = this.#feedTracking.get(key);
338
+ if (!prior || !isDeepStrictEqual(prior.value, value)) this.#feedTracking.set(key, { value, changedAt: now });
339
+ }
340
+ for (const key of this.#feedTracking.keys()) {
341
+ if (!configured.has(key)) this.#feedTracking.delete(key);
342
+ }
343
+
344
+ const states: Record<string, FeedDisplayState> = Object.create(null);
345
+ for (const [key, tracking] of this.#feedTracking) states[key] = getFeedDisplayState(tracking.changedAt, now);
346
+ this.#scheduleFeedTransition(now);
347
+ return states;
348
+ }
349
+
350
+ #scheduleFeedTransition(now: number): void {
351
+ let nextAt: number | undefined;
352
+ for (const { changedAt } of this.#feedTracking.values()) {
353
+ const display = getFeedDisplayState(changedAt, now);
354
+ if (display.phase === "hidden") continue;
355
+ const next =
356
+ display.phase === "active"
357
+ ? changedAt + FEED_HOLD_MS
358
+ : changedAt + FEED_HOLD_MS + (display.fadeShade + 1) * (FEED_FADE_MS / FADE_SHADE_COUNT);
359
+ if (nextAt === undefined || next < nextAt) nextAt = next;
360
+ }
361
+ if (this.#feedTimer && this.#feedTimerAt === nextAt) return;
362
+ if (this.#feedTimer) clearTimeout(this.#feedTimer);
363
+ this.#feedTimer = undefined;
364
+ this.#feedTimerAt = nextAt;
365
+ if (nextAt === undefined) return;
366
+ this.#feedTimer = setTimeout(() => {
367
+ this.#feedTimer = undefined;
368
+ this.#feedTimerAt = undefined;
369
+ this.#requestRender();
370
+ this.#scheduleFeedTransition(Date.now());
371
+ }, Math.max(1, nextAt - now));
372
+ this.#feedTimer.unref?.();
373
+ }
374
+
291
375
  async #refreshPr(): Promise<void> {
292
376
  const cwd = this.#repoCwd;
293
377
  const branch = this.#branch;
@@ -350,13 +434,16 @@ export class SegmentContextBuilder {
350
434
  if (include.pr) void this.#refreshPr();
351
435
  }
352
436
 
437
+ let feedDisplayState: Record<string, FeedDisplayState> | undefined;
353
438
  if (include.feeds.length > 0) {
354
439
  // Editing the subscription list mid-session must not wait out the TTL.
355
- const key = include.feeds.join("\u0000");
356
- if (key !== this.#feedsScanned || now - this.#feedsScannedAt > FEED_TTL_MS) {
357
- this.#feedsScanned = key;
440
+ if (include.feeds !== this.#feedsScannedRef || now - this.#feedsScannedAt > FEED_TTL_MS) {
441
+ this.#feedsScannedRef = include.feeds;
358
442
  this.#refreshFeeds(include.feeds, now);
359
443
  }
444
+ feedDisplayState = this.#syncFeedTracking(options.feeds, now);
445
+ } else {
446
+ this.#clearFeedLifecycle();
360
447
  }
361
448
 
362
449
  const model = ctx?.model
@@ -388,7 +475,8 @@ export class SegmentContextBuilder {
388
475
  scrollHint,
389
476
  piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
390
477
  tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
391
- feedData: include.feeds.length > 0 ? { ...this.#feedData } : undefined,
478
+ feedData: include.feeds.length > 0 ? this.#feedData : undefined,
479
+ feedDisplayState,
392
480
  };
393
481
  }
394
482
  }
package/src/footer.ts CHANGED
@@ -8,6 +8,52 @@ export function stripAnsi(str: string): string {
8
8
  return str.replace(ANSI_RE, "");
9
9
  }
10
10
 
11
+ /** Drop host footer rows that contain only whitespace or SGR control codes. */
12
+ export function filterVisibleRows(rows: readonly string[]): string[] {
13
+ return rows.filter(row => stripAnsi(row).trim().length > 0);
14
+ }
15
+
16
+ interface LayoutEntry {
17
+ component: unknown;
18
+ minSize?: number;
19
+ }
20
+
21
+ function containsComponent(node: unknown, target: unknown, seen: Set<unknown>): boolean {
22
+ if (node === target) return true;
23
+ if (!node || typeof node !== "object" || seen.has(node)) return false;
24
+ seen.add(node);
25
+ const children = (node as { children?: unknown }).children;
26
+ return Array.isArray(children) && children.some(child => containsComponent(child, target, seen));
27
+ }
28
+
29
+ /**
30
+ * Let Pi's fullscreen footer slot collapse when its component renders no rows.
31
+ * Pi 0.84 gives that VStack entry minSize=1, independently of render output.
32
+ */
33
+ export function collapseFooterLayoutSlot(layoutRoot: unknown, footer: unknown): (() => void) | undefined {
34
+ const seen = new Set<unknown>();
35
+ const find = (node: unknown): LayoutEntry | undefined => {
36
+ if (!node || typeof node !== "object" || seen.has(node)) return undefined;
37
+ seen.add(node);
38
+ const entries = (node as { entries?: unknown }).entries;
39
+ if (!Array.isArray(entries)) return undefined;
40
+ for (const entry of entries as LayoutEntry[]) {
41
+ const nested = find(entry.component);
42
+ if (nested) return nested;
43
+ }
44
+ return (entries as LayoutEntry[]).find(entry => containsComponent(entry.component, footer, new Set()));
45
+ };
46
+
47
+ const entry = find(layoutRoot);
48
+ if (!entry) return undefined;
49
+ const previous = entry.minSize;
50
+ entry.minSize = 0;
51
+ return () => {
52
+ if (previous === undefined) delete entry.minSize;
53
+ else entry.minSize = previous;
54
+ };
55
+ }
56
+
11
57
  export function isBorderRow(row: string): boolean {
12
58
  const text = stripAnsi(row);
13
59
  return /^─+$/.test(text) || /^─+ [↑↓] \d+ more\s*─*$/.test(text);
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
 
@@ -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
  });
package/src/theme.ts CHANGED
@@ -178,7 +178,6 @@ type ColorValue = string | number;
178
178
 
179
179
  export type StatusColor =
180
180
  | "accent"
181
- | "success"
182
181
  | "error"
183
182
  | "warning"
184
183
  | "muted"
@@ -193,11 +192,13 @@ export type StatusColor =
193
192
  | "statusLineContextElevated"
194
193
  | "statusLineStaged"
195
194
  | "statusLineDirty"
196
- | "statusLineUntracked";
195
+ | "statusLineUntracked"
196
+ | "statusLineBg";
197
+
198
+ const STATUS_LINE_BG: ColorValue = "#121212";
197
199
 
198
200
  const FG_COLORS: Record<StatusColor, ColorValue> = {
199
201
  accent: "#febc38",
200
- success: "#89d281",
201
202
  error: "#fc3a4b",
202
203
  warning: "#e4c00f",
203
204
  muted: "#777d88",
@@ -213,10 +214,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
213
214
  statusLineStaged: 70,
214
215
  statusLineDirty: 178,
215
216
  statusLineUntracked: 39,
217
+ statusLineBg: STATUS_LINE_BG,
216
218
  };
217
219
 
218
- const STATUS_LINE_BG: ColorValue = "#121212";
219
-
220
220
  /** Dominant palette hexes the session accent must not collide with. */
221
221
  export const MAJOR_COLOR_HEXES = [
222
222
  "#febc38",
@@ -246,7 +246,7 @@ export function parseHex(hex: string): [number, number, number] | undefined {
246
246
  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
247
247
  }
248
248
 
249
- /** Number of discrete, eased shades used for the token-rate fade (pi-topping). */
249
+ /** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
250
250
  export const FADE_SHADE_COUNT = 5;
251
251
 
252
252
  /** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
@@ -275,11 +275,14 @@ function rgbTo256(r: number, g: number, b: number): number {
275
275
  return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
276
276
  }
277
277
 
278
+ function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
279
+ return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
280
+ }
281
+
278
282
  export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
279
283
  const rgb = parseHex(hex);
280
284
  if (!rgb) return "";
281
- const [r, g, b] = rgb;
282
- return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
285
+ return rgbToFgAnsi(...rgb, mode);
283
286
  }
284
287
 
285
288
  function fgAnsi(color: ColorValue, mode: ColorMode): string {
@@ -361,8 +364,7 @@ class StatusTheme {
361
364
  const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
362
365
  const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
363
366
  const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
364
- const ansi =
365
- this.#mode === "truecolor" ? `\x1b[38;2;${r};${g};${bl}m` : `\x1b[38;5;${rgbTo256(r, g, bl)}m`;
367
+ const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
366
368
  return `${ansi}${text}\x1b[39m`;
367
369
  }
368
370
  }
package/src/types.ts CHANGED
@@ -142,6 +142,19 @@ export interface SegmentContext {
142
142
  * entries, and those must not be shown as current.
143
143
  */
144
144
  feedData: Record<string, unknown> | undefined;
145
+ /**
146
+ * Lifecycle state per configured feed field. Missing state is treated as
147
+ * active so previews and manually constructed contexts retain full color.
148
+ */
149
+ feedDisplayState?: Record<string, FeedDisplayState>;
150
+ }
151
+
152
+ export type FeedPhase = "active" | "fading" | "hidden";
153
+
154
+ export interface FeedDisplayState {
155
+ phase: FeedPhase;
156
+ /** Discrete fade step (0..FADE_SHADE_COUNT-1); meaningful while fading. */
157
+ fadeShade: number;
145
158
  }
146
159
 
147
160
  export type TokenRatePhase = "active" | "fading" | "idle";
package/src/utils.ts CHANGED
@@ -65,10 +65,7 @@ export function clampPathLength(pwd: string, maxLen: number): string {
65
65
 
66
66
  /** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
67
67
  export function sanitizeStatusText(text: string): string {
68
- return stripVTControlCharacters(text)
69
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
70
- .replace(/ +/g, " ")
71
- .trim();
68
+ return sanitizeLabel(text).trim();
72
69
  }
73
70
 
74
71
  /**
@@ -77,7 +74,7 @@ export function sanitizeStatusText(text: string): string {
77
74
  */
78
75
  export function sanitizeLabel(text: string): string {
79
76
  return stripVTControlCharacters(text)
80
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
77
+ .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, " ")
81
78
  .replace(/ +/g, " ");
82
79
  }
83
80
 
@@ -85,6 +82,11 @@ export function withIcon(icon: string, text: string): string {
85
82
  return icon ? `${icon} ${text}` : text;
86
83
  }
87
84
 
85
+ /** Unambiguous identity for one configured field of a feed publisher. */
86
+ export function feedKey(customType: string, field: string): string {
87
+ return `${customType}\u0000${field}`;
88
+ }
89
+
88
90
  // ═══════════════════════════════════════════════════════════════════════════
89
91
  // Session accent (djb2 hue hash, dark warm band, collision avoidance)
90
92
  // ═══════════════════════════════════════════════════════════════════════════