@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 +4 -1
- package/index.ts +43 -14
- package/package.json +4 -4
- package/src/box.ts +15 -0
- package/src/context.ts +95 -7
- package/src/footer.ts +46 -0
- package/src/segments.ts +13 -4
- package/src/tests/box.test.ts +14 -1
- package/src/tests/feeds.test.ts +165 -16
- package/src/tests/footer.test.ts +30 -1
- package/src/theme.ts +12 -10
- package/src/types.ts +13 -0
- package/src/utils.ts +7 -5
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.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
"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.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
50
|
-
"@earendil-works/pi-tui": "0.
|
|
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
|
|
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
|
-
#
|
|
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.#
|
|
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
|
-
|
|
356
|
-
|
|
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 ?
|
|
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,
|
|
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
|
|
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:
|
|
285
|
+
return { content: parts.join(" "), visible: true };
|
|
277
286
|
},
|
|
278
287
|
};
|
|
279
288
|
|
package/src/tests/box.test.ts
CHANGED
|
@@ -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) + "╮");
|
package/src/tests/feeds.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 = (
|
|
64
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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}`);
|
package/src/tests/footer.test.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import test from "node:test";
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|