@underactive/pi-topping-statusline 0.1.2 → 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
@@ -41,6 +41,7 @@ and apply live.
41
41
  ║ Symbols ‹ nerdfont › ║
42
42
  ║ Border style ‹ rounded › ║
43
43
  ║ [■] Rainbow border on max thinking ON ║
44
+ ║ [■] Animate rainbow border ON ║
44
45
  ║ ║
45
46
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
46
47
  ║ [■] Pi symbol ON ║
@@ -65,26 +66,26 @@ and apply live.
65
66
  ║ + add feed ║
66
67
  ║ ║
67
68
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
68
- ╚════════════════════════════════════════════════════════[ 1/28 ]╝
69
+ ╚════════════════════════════════════════════════════════[ 1/29 ]╝
69
70
  ```
70
71
 
71
72
  | Section | Settings |
72
73
  | --- | --- |
73
- | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking |
74
+ | Global | Transparent Segments · Separator (`powerline` `powerline-thin` `slash` `pipe` `ascii`) · Symbols (`nerdfont` `unicode` `ascii` — stored in settings.json as `nerd`/`unicode`/`ascii`) · Border style (`rounded` `heavy` `double` `single`) · Rainbow border on max thinking · Animate rainbow border |
74
75
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
75
76
  | Top Right Segment Group | Token rate · Session name |
76
77
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
77
78
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
78
79
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
79
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on |
80
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
80
81
 
81
82
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
82
- replaces the border's fixed theme color with an animated rainbow: a full hue cycle distributed
83
- around the box perimeter that slowly flows around the border (~14s per rotation), like Apple
84
- Intelligence's screen border. Any other thinking level keeps the normal theme border color.
85
- The settings preview animates while the toggle is on; the effect repaints the border about
86
- 20 times a second, so switch it off over slow SSH links or in terminals with expensive
87
- redraws.
83
+ replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
84
+ box perimeter. **Animate rainbow border** is also on by default, so the hues flow around the
85
+ border (~14s per rotation), like Apple Intelligence's screen border. Turn animation off to keep
86
+ the rainbow at a fixed color phase without its repaint timer; the settings preview uses a stable
87
+ phase too. Any other thinking level keeps the normal theme border color. Disable animation over
88
+ slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
88
89
 
89
90
  ## Segments
90
91
 
@@ -128,11 +129,15 @@ early in a session, before the cache-write premium is amortized by reads. Above
128
129
 
129
130
  A feed contributes nothing when its publisher is absent, has published nothing this run, or
130
131
  reports a value its format suppresses; the segment disappears entirely once every feed is
131
- 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,
132
136
  so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
133
- ignored rather than shown as a stale total. The session is re-read at most once every two
134
- seconds, because pi copies the whole entry list on each read while the bar re-renders on
135
- every keystroke.
137
+ ignored rather than shown as a stale total. During steady-state rendering, the session is
138
+ re-read at most once every two seconds, because pi copies the whole entry list on each read
139
+ while the bar re-renders on every keystroke; changing subscriptions or starting a new
140
+ session forces an immediate scan.
136
141
 
137
142
  ### Publishing a feed
138
143
 
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);
@@ -55,17 +62,18 @@ export default function (pi: ExtensionAPI) {
55
62
  rateMonitor.attach(pi);
56
63
  builder.setTokenRateProvider(() => rateMonitor.getDisplay());
57
64
 
58
- // Animated rainbow border while the thinking level is max: a timer steps
59
- // the hue phase and re-renders; renderBoxed paints the box glyphs from it.
65
+ // The rainbow is visible at max thinking independently of whether its hue
66
+ // phase is animated. Stopping the timer preserves the current live phase.
60
67
  const rainbow = new RainbowBorder();
61
68
  let rainbowTimer: ReturnType<typeof setInterval> | undefined;
62
69
  const rainbowActive = (): boolean =>
63
70
  state.effective.rainbowBorder && activeCtx !== undefined && pi.getThinkingLevel() === "max";
71
+ const rainbowAnimationActive = (): boolean => rainbowActive() && state.effective.rainbowAnimation;
64
72
  const syncRainbow = (): void => {
65
- if (rainbowActive()) {
73
+ if (rainbowAnimationActive()) {
66
74
  if (!rainbowTimer) {
67
75
  rainbowTimer = setInterval(() => {
68
- if (!rainbowActive()) {
76
+ if (!rainbowAnimationActive()) {
69
77
  syncRainbow();
70
78
  return;
71
79
  }
@@ -148,7 +156,7 @@ export default function (pi: ExtensionAPI) {
148
156
  },
149
157
  { col: 3, row: bottomIdx },
150
158
  );
151
- out.push(renderBoxRow(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
159
+ out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
152
160
  for (let i = bottomIdx + 1; i < lines.length; i++) {
153
161
  out.push(` ${lines[i]}`);
154
162
  }
@@ -162,7 +170,21 @@ export default function (pi: ExtensionAPI) {
162
170
  ? inner(tui, editorTheme, keybindings)
163
171
  : new CustomEditor(tui, editorTheme, keybindings);
164
172
  const innerRender = editor.render.bind(editor);
165
- 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
+ };
166
188
  return editor;
167
189
  };
168
190
  };
@@ -211,17 +233,23 @@ export default function (pi: ExtensionAPI) {
211
233
  // its cwd and stats lines, which the box now carries itself.
212
234
  const ensureFooterPatched = () => {
213
235
  const tui = activeTui;
214
- if (!tui || patchedFooter) return;
215
- let footer: Component | undefined;
216
- for (const child of tui.children) {
217
- footer = findFooter(child);
218
- 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);
219
252
  }
220
- if (!footer) return;
221
- const orig = footer.render.bind(footer);
222
- footerRenderOriginal = orig;
223
- footer.render = width => orig(width).slice(2);
224
- patchedFooter = footer;
225
253
  };
226
254
 
227
255
  pi.on("model_select", () => requestRender());
@@ -266,8 +294,10 @@ export default function (pi: ExtensionAPI) {
266
294
  if (patchedFooter && footerRenderOriginal) {
267
295
  patchedFooter.render = footerRenderOriginal;
268
296
  }
297
+ restoreFooterLayout?.();
269
298
  patchedFooter = undefined;
270
299
  footerRenderOriginal = undefined;
300
+ restoreFooterLayout = undefined;
271
301
  ourFactory = undefined;
272
302
  wrappedFactory = undefined;
273
303
  activeTui = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-statusline",
3
- "version": "0.1.2",
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 ─────────────────────────────────────────────────────────
@@ -191,6 +226,9 @@ export class SegmentContextBuilder {
191
226
  this.#requestRender();
192
227
  } finally {
193
228
  this.#repoResolving = false;
229
+ if (this.#repoCwd && this.#repoCwd !== cwd) {
230
+ void this.#resolveRepo(this.#repoCwd);
231
+ }
194
232
  }
195
233
  }
196
234
 
@@ -278,12 +316,62 @@ export class SegmentContextBuilder {
278
316
  // first: matching on customType alone lets one shadow a real entry.
279
317
  if (entry?.type !== "custom" || !pending.has(entry.customType)) continue;
280
318
  const ts = Date.parse(entry.timestamp);
281
- if (!Number.isFinite(ts) || ts < this.#runStartedAt) break;
319
+ if (!Number.isFinite(ts)) continue;
320
+ if (ts < this.#runStartedAt) break;
282
321
  pending.delete(entry.customType);
283
322
  if (entry.data !== undefined) this.#feedData[entry.customType] = entry.data;
284
323
  }
285
324
  }
286
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
+
287
375
  async #refreshPr(): Promise<void> {
288
376
  const cwd = this.#repoCwd;
289
377
  const branch = this.#branch;
@@ -307,7 +395,7 @@ export class SegmentContextBuilder {
307
395
  const u = URL.parse(parsed.url);
308
396
  if (
309
397
  u &&
310
- (u.protocol === "https:" || u.protocol === "http:") &&
398
+ u.protocol === "https:" &&
311
399
  u.hostname === "github.com" &&
312
400
  !/[\u0000-\u001f\u007f]/.test(parsed.url)
313
401
  ) {
@@ -346,13 +434,16 @@ export class SegmentContextBuilder {
346
434
  if (include.pr) void this.#refreshPr();
347
435
  }
348
436
 
437
+ let feedDisplayState: Record<string, FeedDisplayState> | undefined;
349
438
  if (include.feeds.length > 0) {
350
439
  // Editing the subscription list mid-session must not wait out the TTL.
351
- const key = include.feeds.join("\u0000");
352
- if (key !== this.#feedsScanned || now - this.#feedsScannedAt > FEED_TTL_MS) {
353
- this.#feedsScanned = key;
440
+ if (include.feeds !== this.#feedsScannedRef || now - this.#feedsScannedAt > FEED_TTL_MS) {
441
+ this.#feedsScannedRef = include.feeds;
354
442
  this.#refreshFeeds(include.feeds, now);
355
443
  }
444
+ feedDisplayState = this.#syncFeedTracking(options.feeds, now);
445
+ } else {
446
+ this.#clearFeedLifecycle();
356
447
  }
357
448
 
358
449
  const model = ctx?.model
@@ -384,7 +475,8 @@ export class SegmentContextBuilder {
384
475
  scrollHint,
385
476
  piStats: include.piStats ? this.#piStatsProvider?.(width) : undefined,
386
477
  tokenRate: include.tokenRate ? this.#tokenRateProvider?.() : undefined,
387
- feedData: include.feeds.length > 0 ? { ...this.#feedData } : undefined,
478
+ feedData: include.feeds.length > 0 ? this.#feedData : undefined,
479
+ feedDisplayState,
388
480
  };
389
481
  }
390
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
 
@@ -135,6 +135,11 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
135
135
  label: "Rainbow border on max thinking",
136
136
  value: settings.rainbowBorder ?? true,
137
137
  },
138
+ {
139
+ id: "rainbowAnimation",
140
+ label: "Animate rainbow border",
141
+ value: settings.rainbowAnimation ?? true,
142
+ },
138
143
  ],
139
144
  };
140
145
  return [
@@ -164,6 +169,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
164
169
  segments,
165
170
  feeds: sanitizeFeeds(feedsFromValues(values)),
166
171
  rainbowBorder: values.rainbowBorder === true,
172
+ rainbowAnimation: values.rainbowAnimation === true,
167
173
  };
168
174
  }
169
175
 
@@ -195,12 +201,10 @@ function cannedFeedData(
195
201
  }
196
202
 
197
203
  class StatusLinePreview {
198
- readonly #state: SettingsState;
199
204
  readonly #builder: SegmentContextBuilder;
200
205
  readonly #uiTheme: Theme;
201
206
 
202
- constructor(state: SettingsState, builder: SegmentContextBuilder, uiTheme: Theme) {
203
- this.#state = state;
207
+ constructor(builder: SegmentContextBuilder, uiTheme: Theme) {
204
208
  this.#builder = builder;
205
209
  this.#uiTheme = uiTheme;
206
210
  }
@@ -217,6 +221,7 @@ class StatusLinePreview {
217
221
  const boxWidth = Math.max(24, innerWidth - 1);
218
222
  const barWidth = boxWidth - 6;
219
223
  const rainbowOn = effective.rainbowBorder;
224
+ const rainbowAnimationOn = rainbowOn && effective.rainbowAnimation;
220
225
 
221
226
  // The theme singleton drives glyph lookups everywhere, so swap the
222
227
  // symbol preset for this synchronous render only; the live bar behind
@@ -255,7 +260,7 @@ class StatusLinePreview {
255
260
  const bottomIdx = 2;
256
261
  const painters = makeBoxPainters({
257
262
  rainbowOn,
258
- rainbow: new RainbowBorder((elapsedMs * 360) / RAINBOW_CYCLE_MS),
263
+ rainbow: new RainbowBorder(rainbowAnimationOn ? (elapsedMs * 360) / RAINBOW_CYCLE_MS : 0),
259
264
  box,
260
265
  width: boxWidth,
261
266
  bottomIdx,
@@ -291,9 +296,8 @@ class StatusLinePreview {
291
296
  painters.paint(1, boxWidth - 1, box.vertical),
292
297
  renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
293
298
  ],
294
- // Keep the preview animated only while the rainbow is enabled;
295
- // a static preview refreshes once and stops.
296
- nextRefreshInMs: rainbowOn ? RAINBOW_FRAME_MS : undefined,
299
+ // A static rainbow stays at phase zero and needs no repaint timer.
300
+ nextRefreshInMs: rainbowAnimationOn ? RAINBOW_FRAME_MS : undefined,
297
301
  };
298
302
  } finally {
299
303
  theme.setSymbolPreset(applied);
@@ -308,13 +312,13 @@ export function registerSettingsCommand(
308
312
  onChange: () => void,
309
313
  ): void {
310
314
  pi.registerCommand("topping-statusline-settings", {
311
- description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border",
315
+ description: "Configure transparent segments, statusline segments, separator, symbols, border style, feeds, and rainbow border animation",
312
316
  handler: async (_args, ctx: ExtensionCommandContext) => {
313
317
  if (ctx.mode !== "tui") {
314
318
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
315
319
  return;
316
320
  }
317
- const preview = new StatusLinePreview(state, builder, ctx.ui.theme);
321
+ const preview = new StatusLinePreview(builder, ctx.ui.theme);
318
322
  const result = await showMenu<Record<string, MenuValue>>(ctx, {
319
323
  title: "Pi Topping Statusline: Settings",
320
324
  sections: buildSections(state.settings),
package/src/settings.ts CHANGED
@@ -92,6 +92,7 @@ function loadSettings(): StatusLineSettings {
92
92
  }
93
93
  if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
94
94
  if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
95
+ if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
95
96
  return settings;
96
97
  } catch {
97
98
  // Missing or corrupt settings.json falls back to defaults.
@@ -153,6 +154,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
153
154
  bottomLeftSegments,
154
155
  bottomRightSegments,
155
156
  rainbowBorder: settings.rainbowBorder ?? true,
157
+ rainbowAnimation: settings.rainbowAnimation ?? true,
156
158
  includes,
157
159
  segmentOptions: {
158
160
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
@@ -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
  });
@@ -113,9 +113,11 @@ test("a left group shifts the gap start by its width", () => {
113
113
  assert.ok(calls[0]!.startCol > 3, "gap starts after the pi segment, not at the origin");
114
114
  });
115
115
 
116
- test("rainbowBorder defaults to true and is loadable", () => {
116
+ test("rainbow settings default to true and resolve explicit false", () => {
117
117
  assert.equal(resolveEffectiveSettings({}).rainbowBorder, true);
118
+ assert.equal(resolveEffectiveSettings({}).rainbowAnimation, true);
118
119
  assert.equal(resolveEffectiveSettings({ rainbowBorder: false }).rainbowBorder, false);
120
+ assert.equal(resolveEffectiveSettings({ rainbowAnimation: false }).rainbowAnimation, false);
119
121
  });
120
122
 
121
123
  test("borderStyle defaults to rounded and is loadable", () => {
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
@@ -69,8 +69,10 @@ export interface StatusLineSettings {
69
69
  borderStyle?: BorderStyle;
70
70
  segments?: StatusLineSegmentToggles;
71
71
  feeds?: StatusLineFeed[];
72
- /** Animated rainbow border while the thinking level is max. */
72
+ /** Rainbow border while the thinking level is max. */
73
73
  rainbowBorder?: boolean;
74
+ /** Animate the rainbow border when it is visible. */
75
+ rainbowAnimation?: boolean;
74
76
  }
75
77
 
76
78
  /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
@@ -102,6 +104,7 @@ export interface EffectiveStatusLineSettings {
102
104
  bottomRightSegments: StatusLineSegmentId[];
103
105
  segmentOptions: StatusLineSegmentOptions;
104
106
  rainbowBorder: boolean;
107
+ rainbowAnimation: boolean;
105
108
  includes: SegmentIncludes;
106
109
  }
107
110
 
@@ -139,6 +142,19 @@ export interface SegmentContext {
139
142
  * entries, and those must not be shown as current.
140
143
  */
141
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;
142
158
  }
143
159
 
144
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
  // ═══════════════════════════════════════════════════════════════════════════