@underactive/pi-topping-statusline 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,7 @@ and apply live.
42
42
  ║ Border style ‹ rounded › ║
43
43
  ║ [■] Rainbow border on max thinking ON ║
44
44
  ║ [■] Animate rainbow border ON ║
45
+ ║ [ ] Embed 'Working' indicator OFF ║
45
46
  ║ ║
46
47
  ╟─ Top Left Segment Group ───────────────────────────────────────╢
47
48
  ║ [■] Pi symbol ON ║
@@ -66,18 +67,18 @@ and apply live.
66
67
  ║ + add feed ║
67
68
  ║ ║
68
69
  ║ ↑↓ move ←→ cycle ␣ toggle ⏎ apply/edit esc cancel ║
69
- ╚════════════════════════════════════════════════════════[ 1/29 ]╝
70
+ ╚════════════════════════════════════════════════════════[ 1/30 ]╝
70
71
  ```
71
72
 
72
73
  | Section | Settings |
73
74
  | --- | --- |
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 |
75
+ | 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 · Embed 'Working' indicator |
75
76
  | Top Left Segment Group | Pi symbol · Model · Provider · Thinking level · Path · Git · PR |
76
77
  | Top Right Segment Group | Token rate · Session name |
77
78
  | Bottom Right Segment Group | Feeds · Token rate · Pi stats · Context bar · Context stats |
78
79
  | Bottom Left Segment Group | Scroll hint · Feeds · Token rate |
79
80
  | Feeds | One subscription per row: type · field · prefix · format, plus add/remove |
80
- | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on |
81
+ | Defaults | Transparent on · Separator `powerline-thin` · Symbols `nerdfont` · Border style `rounded` · Rainbow border on · Animate rainbow border on · Embed 'Working' indicator off |
81
82
 
82
83
  With **Rainbow border on max thinking** on (the default), cycling the thinking level to `max`
83
84
  replaces the border's fixed theme color with a rainbow: a full hue cycle distributed around the
@@ -87,6 +88,17 @@ the rainbow at a fixed color phase without its repaint timer; the settings previ
87
88
  phase too. Any other thinking level keeps the normal theme border color. Disable animation over
88
89
  slow SSH links or in terminals with expensive redraws while retaining the rainbow border.
89
90
 
91
+ **Embed 'Working' indicator** (off by default) moves pi's streaming status — spinner, message,
92
+ and any loader text a topping such as pi-topping supplies — out of its own row and into the
93
+ top-left group, right after the Pi symbol and its chevron. The remaining left segments (model,
94
+ path, git, PR) step aside while a response streams and return when it ends; the Pi symbol never
95
+ moves. The status appears instantly when a stream starts; when it ends, the swap back
96
+ cross-fades over 750ms (the status sinks into the bar background, then the user's segments
97
+ rise out of it) rather than cutting. A status too long for the bar is truncated without an ellipsis, as pi's own border does.
98
+ Requires pi 0.85 or later. Toggling the setting reinstalls the editor through pi, so it applies
99
+ immediately, even mid-response. Only the statusline's own editor opts in: when another
100
+ extension owns the editor slot and is wrapped, pi keeps its standalone working row.
101
+
90
102
  ## Segments
91
103
 
92
104
  Ported with pi data: `pi`, `model` (model · provider · thinking level), `path`
@@ -129,7 +141,10 @@ early in a session, before the cache-write premium is amortized by reads. Above
129
141
 
130
142
  A feed contributes nothing when its publisher is absent, has published nothing this run, or
131
143
  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,
144
+ quiet, so it costs nothing to leave switched on. Feed values are shown in dim text; a value
145
+ that remains unchanged for five minutes fades into the statusline background over 500ms and
146
+ then hides. It reappears in dim text only when that configured field's value changes.
147
+ Publishers typically reset per process run,
133
148
  so entries predating the current session start (`--resume`, `/reload`, `/new`, fork) are
134
149
  ignored rather than shown as a stale total. During steady-state rendering, the session is
135
150
  re-read at most once every two seconds, because pi copies the whole entry list on each read
package/index.ts CHANGED
@@ -25,17 +25,28 @@ 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";
34
- import { createSettingsState } from "./src/settings.js";
40
+ import { createSettingsState, topLeftSegments } from "./src/settings.js";
41
+ import { easeFade } from "./src/theme.js";
35
42
  import { theme } from "./src/theme.js";
36
43
  import { TokenRateMonitor } from "./src/token-rate.js";
37
44
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
38
45
 
46
+ /** Cross-fade budget when the embedded working status disappears: half out, half in. */
47
+ const WORKING_FADE_MS = 750;
48
+ const WORKING_FADE_FRAME_MS = 15;
49
+
39
50
  export default function (pi: ExtensionAPI) {
40
51
  const state = createSettingsState();
41
52
  const builder = new SegmentContextBuilder(pi);
@@ -47,9 +58,78 @@ export default function (pi: ExtensionAPI) {
47
58
  let ensureTimer: ReturnType<typeof setInterval> | undefined;
48
59
  let patchedFooter: Component | undefined;
49
60
  let footerRenderOriginal: ((width: number) => string[]) | undefined;
61
+ let restoreFooterLayout: (() => void) | undefined;
62
+ // The CustomEditor this extension constructed itself (undefined when it wraps
63
+ // another extension's editor), and the host's working indicator captured
64
+ // from that editor while a response streams.
65
+ let activeEditor: CustomEditor | undefined;
66
+ let embeddedWorkingStatus: ((width: number) => string) | undefined;
67
+ // Fade from the working status back to the user's left segments when a
68
+ // stream ends. The last status text is kept so the outgoing half still has
69
+ // something to fade after pi has cleared its indicator.
70
+ let workingShown = false;
71
+ let lastWorkingText: string | undefined;
72
+ let workingFade: { start: number } | undefined;
73
+ let workingFadeTimer: ReturnType<typeof setInterval> | undefined;
74
+ const stopWorkingFadeTimer = (): void => {
75
+ if (workingFadeTimer) clearInterval(workingFadeTimer);
76
+ workingFadeTimer = undefined;
77
+ };
78
+
79
+ const syncEmbed = (): void => {
80
+ const ctx = activeCtx;
81
+ const factory = ourFactory;
82
+ if (!activeEditor || !ctx || !factory) return;
83
+ const next = state.effective.embedWorkingStatus;
84
+ if (activeEditor.embedWorkingStatus === next) return;
85
+ ctx.ui.setEditorComponent(factory);
86
+ };
50
87
 
51
88
  const requestRender = () => activeTui?.requestRender();
52
89
  builder.setRequestRender(requestRender);
90
+ const startWorkingFadeTimer = (): void => {
91
+ if (workingFadeTimer) return;
92
+ workingFadeTimer = setInterval(() => {
93
+ if (!workingFade || Date.now() - workingFade.start >= WORKING_FADE_MS) {
94
+ workingFade = undefined;
95
+ stopWorkingFadeTimer();
96
+ }
97
+ requestRender();
98
+ }, WORKING_FADE_FRAME_MS);
99
+ workingFadeTimer.unref?.();
100
+ };
101
+
102
+ /**
103
+ * Which left group this frame shows and how far it has faded. A stream
104
+ * starting shows the working status at once. A stream ending starts a
105
+ * 750ms clock: the status sinks into the bar background for the first
106
+ * half, then the user's segments rise out of it.
107
+ */
108
+ const resolveWorkingFrame = (live: string | undefined): { working: string | undefined; leftFade?: number } => {
109
+ const now = Date.now();
110
+ const liveShown = live !== undefined;
111
+ if (liveShown) lastWorkingText = live;
112
+ if (liveShown !== workingShown) {
113
+ workingShown = liveShown;
114
+ if (liveShown) {
115
+ // Instant cut in, and it cancels any fade-out still running.
116
+ workingFade = undefined;
117
+ } else {
118
+ workingFade = { start: now };
119
+ startWorkingFadeTimer();
120
+ }
121
+ }
122
+ if (!workingFade) return { working: live };
123
+ const half = WORKING_FADE_MS / 2;
124
+ const elapsed = now - workingFade.start;
125
+ if (elapsed >= WORKING_FADE_MS) {
126
+ workingFade = undefined;
127
+ return { working: live };
128
+ }
129
+ const outgoing = elapsed < half;
130
+ const leftFade = easeFade(outgoing ? 1 - elapsed / half : (elapsed - half) / half);
131
+ return { working: outgoing ? lastWorkingText : undefined, leftFade };
132
+ };
53
133
 
54
134
  const rateMonitor = new TokenRateMonitor(requestRender);
55
135
  rateMonitor.attach(pi);
@@ -83,6 +163,7 @@ export default function (pi: ExtensionAPI) {
83
163
 
84
164
  registerSettingsCommand(pi, state, builder, () => {
85
165
  syncRainbow();
166
+ syncEmbed();
86
167
  requestRender();
87
168
  });
88
169
 
@@ -120,16 +201,20 @@ export default function (pi: ExtensionAPI) {
120
201
  const rainbowOn = rainbowActive();
121
202
  const box = theme.getBox(effective.borderStyle);
122
203
  const painters = makeBoxPainters({ rainbowOn, rainbow, box, width, bottomIdx, flat: border });
204
+ // The layout truncates the status to fit, so it is rendered at full width here.
205
+ const live = effective.embedWorkingStatus ? embeddedWorkingStatus?.(innerWidth) || undefined : undefined;
206
+ const { working, leftFade } = resolveWorkingFrame(live);
123
207
  const bar = buildStatusLine(
124
208
  innerWidth,
125
- segCtx,
209
+ { ...segCtx, workingStatus: working },
126
210
  effective,
127
211
  painters.gapColor,
128
212
  {
129
- left: effective.leftSegments,
213
+ left: topLeftSegments(effective, working !== undefined),
130
214
  right: effective.rightSegments,
131
215
  },
132
216
  { col: 3, row: 0 },
217
+ { leftFade },
133
218
  );
134
219
  // Leading spacer row: keeps the transcript from sitting flush on the box.
135
220
  const out: string[] = ["", renderBoxRow(painters, bar, 0, width, box.topLeft, box.topRight)];
@@ -149,7 +234,7 @@ export default function (pi: ExtensionAPI) {
149
234
  },
150
235
  { col: 3, row: bottomIdx },
151
236
  );
152
- out.push(renderBoxRow(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
237
+ out.push(...renderBoxRowIfVisible(painters, bottomBar, bottomIdx, width, box.bottomLeft, box.bottomRight));
153
238
  for (let i = bottomIdx + 1; i < lines.length; i++) {
154
239
  out.push(` ${lines[i]}`);
155
240
  }
@@ -159,11 +244,45 @@ export default function (pi: ExtensionAPI) {
159
244
  const wrapFactory = (inner: EditorFactory | undefined): EditorFactory => {
160
245
  return (tui, editorTheme, keybindings) => {
161
246
  activeTui = tui;
162
- const editor = inner
163
- ? inner(tui, editorTheme, keybindings)
164
- : new CustomEditor(tui, editorTheme, keybindings);
247
+ let editor: ReturnType<EditorFactory>;
248
+ if (inner) {
249
+ // A wrapped third-party editor is not opted in: pi keeps its
250
+ // standalone working row for it.
251
+ editor = inner(tui, editorTheme, keybindings);
252
+ activeEditor = undefined;
253
+ embeddedWorkingStatus = undefined;
254
+ } else {
255
+ const own = new CustomEditor(tui, editorTheme, keybindings, {
256
+ embedWorkingStatus: state.effective.embedWorkingStatus,
257
+ });
258
+ // Capture the host's indicator on its way in; renderBoxed draws it
259
+ // into the top bar since the inner top border (lines[0]) is discarded.
260
+ if (typeof own.setWorkingStatusIndicator === "function") {
261
+ const setIndicator = own.setWorkingStatusIndicator.bind(own);
262
+ own.setWorkingStatusIndicator = indicator => {
263
+ embeddedWorkingStatus = indicator ? width => indicator.renderInBorder(width) : undefined;
264
+ setIndicator(indicator);
265
+ };
266
+ }
267
+ activeEditor = own;
268
+ editor = own;
269
+ }
165
270
  const innerRender = editor.render.bind(editor);
166
- editor.render = width => renderBoxed(innerRender, width, editor);
271
+ let boxRenderFailed = false;
272
+ editor.render = width => {
273
+ try {
274
+ return renderBoxed(innerRender, width, editor);
275
+ } catch (err) {
276
+ if (!boxRenderFailed) {
277
+ boxRenderFailed = true;
278
+ activeCtx?.ui.notify(
279
+ `Statusline render failed, using plain editor: ${err instanceof Error ? err.message : String(err)}`,
280
+ "error",
281
+ );
282
+ }
283
+ return innerRender(width);
284
+ }
285
+ };
167
286
  return editor;
168
287
  };
169
288
  };
@@ -212,17 +331,23 @@ export default function (pi: ExtensionAPI) {
212
331
  // its cwd and stats lines, which the box now carries itself.
213
332
  const ensureFooterPatched = () => {
214
333
  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;
334
+ if (!tui) return;
335
+ if (!patchedFooter) {
336
+ let footer: Component | undefined;
337
+ for (const child of tui.children) {
338
+ footer = findFooter(child);
339
+ if (footer) break;
340
+ }
341
+ if (!footer) return;
342
+ const orig = footer.render.bind(footer);
343
+ footerRenderOriginal = orig;
344
+ footer.render = width => filterVisibleRows(orig(width).slice(2));
345
+ patchedFooter = footer;
346
+ }
347
+ if (!restoreFooterLayout) {
348
+ const layoutRoot = (tui as unknown as { layoutRoot?: unknown }).layoutRoot;
349
+ restoreFooterLayout = collapseFooterLayoutSlot(layoutRoot, patchedFooter);
220
350
  }
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
351
  };
227
352
 
228
353
  pi.on("model_select", () => requestRender());
@@ -239,6 +364,7 @@ export default function (pi: ExtensionAPI) {
239
364
  rateMonitor.enable();
240
365
  syncRainbow();
241
366
  ensureInstalled();
367
+ syncEmbed();
242
368
  ensureFooterPatched();
243
369
  ensureTimer ??= setInterval(() => {
244
370
  ensureInstalled();
@@ -267,10 +393,18 @@ export default function (pi: ExtensionAPI) {
267
393
  if (patchedFooter && footerRenderOriginal) {
268
394
  patchedFooter.render = footerRenderOriginal;
269
395
  }
396
+ restoreFooterLayout?.();
270
397
  patchedFooter = undefined;
271
398
  footerRenderOriginal = undefined;
399
+ restoreFooterLayout = undefined;
272
400
  ourFactory = undefined;
273
401
  wrappedFactory = undefined;
402
+ activeEditor = undefined;
403
+ embeddedWorkingStatus = undefined;
404
+ stopWorkingFadeTimer();
405
+ workingFade = undefined;
406
+ workingShown = false;
407
+ lastWorkingText = undefined;
274
408
  activeTui = undefined;
275
409
  activeCtx = undefined;
276
410
  });
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.2.0",
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,10 @@
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.85.0",
49
+ "@earendil-works/pi-coding-agent": "0.85.0",
50
+ "@earendil-works/pi-server": "0.85.0",
51
+ "@earendil-works/pi-tui": "0.85.0",
51
52
  "@types/node": "^22.0.0",
52
53
  "typescript": "^5.7.0"
53
54
  }
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;
@@ -40,7 +63,7 @@ function deriveContextUsage(ctx: ExtensionContext | undefined): {
40
63
  const usage = ctx?.getContextUsage();
41
64
  const contextWindow = usage?.contextWindow ?? ctx?.model?.contextWindow ?? 0;
42
65
  const tokens = usage?.tokens ?? undefined;
43
- const percent = usage ? usage.percent : contextWindow > 0 && tokens != null ? (tokens / contextWindow) * 100 : null;
66
+ const percent = usage ? usage.percent : null;
44
67
  return { contextWindow, tokens, percent };
45
68
  }
46
69
 
@@ -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);